Fix text editing layout and preserve source PDF font styles (#36)

* Comments: a text box's rectangle follows its edited text

Editing a text box (or typewriter or callout) rewrote /Contents but
kept the old /Rect: the appearance clipped the new text to the old box
and silently dropped lines that no longer fit, while shorter text left
an oversized box. set_contents now re-measures the text with the same
Helvetica widths as at creation and re-fits the box: the wrap width and
top edge stay, the height fits the wrapped lines. A callout's leader
line keeps its place and re-attaches to the moved box (that geometry
moved from set_rect into a shared apply_text_box). A locked box keeps
its text editable, so it re-fits too.

Covered by annot tests (grow and shrink with nothing clipped, callout
leader re-attach, locked box) and a comment_edit round trip through the
automation tools; parity entries cite them.

Co-Authored-By: GLM <[email protected]>

* Polish edit text and images tool

* feat(edit-text): select source font style

* fix(edit-text): preserve detected PDF font styles

* feat(edit-text): support Japanese replacements

* fix(forms): escape non-ASCII format script strings

* Forms: unwrap_or instead of map_or with identity (clippy map_or_identity)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: GLM <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-05 07:48:23 -04:00
1 parent 2f364665b8
commit e59c9a793e
19 files changed
+1060 -93

No files matched your search

+3 -1
View File
@@ -4,7 +4,7 @@
Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (242)
## In this repository (244)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -186,6 +186,8 @@ Every asset PrintCraft includes, bundles or uses to build its published material
| `assets/fonts/Inter-Medium.ttf` | Inter Medium | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
| `assets/fonts/Inter-SemiBold.ttf` | Inter SemiBold | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono Regular | The JetBrains Mono Project Authors | OFL-1.1 | https://github.com/JetBrains/JetBrainsMono | UI monospace font; demo PDF code |
| `assets/fonts/ShipporiMincho-Regular.ttf` | Shippori Mincho Regular | The Shippori Mincho Project Authors (FONTDASU) | OFL-1.1 | https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho/ShipporiMincho-Regular.ttf | Japanese fallback glyphs in edited PDF text and the edit-text UI |
| `assets/fonts/OFL-ShipporiMincho.txt` | SIL Open Font License 1.1 for Shippori Mincho | The Shippori Mincho Project Authors (FONTDASU) | OFL-1.1 | https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho/OFL.txt | License for Shippori Mincho fallback font |
| `vendor/hayro-interpret/assets/FoxitDingbats.pfb` | FoxitDingbats (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/FoxitFixed.pfb` | FoxitFixed (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/FoxitFixedBold.pfb` | FoxitFixedBold (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
+22
View File
@@ -1966,6 +1966,28 @@ kind = "font"
usage = "UI monospace font; demo PDF code"
sha256 = "e6fd0d7e91550b3ed2b735d4312474362c4716edc4fc0577a0f61ed782d5aed1"
[[asset]]
path = "assets/fonts/ShipporiMincho-Regular.ttf"
title = "Shippori Mincho Regular"
author = "The Shippori Mincho Project Authors (FONTDASU)"
source = "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho/ShipporiMincho-Regular.ttf"
licence = "OFL-1.1"
licence_file = "assets/fonts/OFL-ShipporiMincho.txt"
kind = "font"
usage = "Japanese fallback glyphs in edited PDF text and the edit-text UI"
sha256 = "769b5269f0f9bc6534b352c0e6bd856a566e03ff788f107191c2d835863570b2"
[[asset]]
path = "assets/fonts/OFL-ShipporiMincho.txt"
title = "SIL Open Font License 1.1 for Shippori Mincho"
author = "The Shippori Mincho Project Authors (FONTDASU)"
source = "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho/OFL.txt"
licence = "OFL-1.1"
licence_file = "assets/fonts/OFL-ShipporiMincho.txt"
kind = "licence"
usage = "License for Shippori Mincho fallback font"
sha256 = "41fba056279be5f45ff9a99e44b7b53897b42732f5806d8e666e0ab49ac6bd38"
[[asset]]
path = "vendor/hayro-interpret/assets/FoxitDingbats.pfb"
title = "FoxitDingbats (standard-14 substitute font)"
+93
View File
@@ -0,0 +1,93 @@
Copyright 2021 The Shippori Mincho Project Authors (https://github.com/fontdasu/ShipporiMincho)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+45 -2
View File
@@ -1150,7 +1150,8 @@ pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Resul
set_annots(doc, p, kept)
}
/// Change a comment's text. Text boxes are redrawn to show it.
/// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the
/// new text: the wrap width and top edge stay, the height fits the wrapped lines.
pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText");
@@ -1165,11 +1166,46 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m
touch(d, meta);
})?;
if free_text {
set_appearance(doc, r)?;
// The box grows and shrinks with the text instead of clipping it. A locked box keeps its
// text editable (as in Acrobat), so the re-fit ignores the lock.
match fitted_box(doc, r, text) {
Some(rect) => apply_text_box(doc, r, rect, meta)?,
None => set_appearance(doc, r)?,
}
}
Ok(())
}
/// The rectangle a FreeText annotation's text box needs for `text`: the current wrap width and
/// top edge stay and the height fits the wrapped lines, as at creation. `None` keeps the old
/// rectangle (the box can't be measured, so only the appearance is redrawn).
fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
let d = annot_dict(doc, r);
let nums = |key: &[u8]| -> Option<Vec<f64>> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() };
let (_, size) = appearance::parse_da(&d);
let pad = 2.0 + border_width_of(&d);
let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?;
let rect = [rect[0], rect[1], rect[2], rect[3]];
// A callout re-fits its text box (`/Rect` inset by `/RD`); the leader line keeps its place.
let tb = if d.contains(b"CL") {
let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| *x >= 0.0))?;
[rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]]
} else {
rect
};
let (x0, top) = (tb[0].min(tb[2]), tb[1].max(tb[3]));
let w = tb[2] - tb[0];
let w = if w.is_finite() && w >= 1.0 {
w
} else {
// No usable old width: fall back to the creation-time width (at most 300 pt).
let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
(longest + 2.0 * pad + 4.0).clamp(40.0, 300.0)
};
let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64;
Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top])
}
/// Reply to a comment; returns the reply's index in the page's `/Annots`.
///
/// A reply is a text annotation with `/IRT` pointing at its parent (§12.5.6.2). It gets the
@@ -1326,10 +1362,17 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") {
return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized")));
}
apply_text_box(doc, r, rect, meta)
}
/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn.
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
let rect = normalize(rect);
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
}
let d = annot_dict(doc, r);
// A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it.
let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::<Vec<f64>>()).filter(|l| l.len() == 6);
let (outer, cl) = match callout {
+74
View File
@@ -279,6 +279,80 @@ fn text_box_text_and_colour_changes_are_drawn() {
assert!(rc.starts_with("<?xml") && rc.contains("<p dir=\"ltr\">Caf\u{e9} (draft) \u{2014} 100%</p>") && rc.contains("color:#FF0000"), "{rc}");
}
/// The rectangle a text box needs for `text` (the appearance's padding plus the 2 pt slack):
/// the wrap width and top edge stay, the height fits the wrapped lines.
fn expected_box(top: f64, w: f64, text: &str, size: f64, border: f64) -> [f64; 4] {
let pad = 2.0 + border;
let lines = appearance::wrap(text, size, w - 2.0 * pad).len().max(1) as f64;
[0.0, top - lines * size * 1.2 - 2.0 * pad - 2.0, w, 0.0]
}
#[test]
fn editing_a_text_box_refits_its_rectangle() {
let mut doc = fixture();
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 304.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap();
// The style's border is 2 pt wide, so the text is padded by 2 + 2.
let long = "the quick brown fox jumps over the lazy dog and keeps going";
set_contents(&mut doc, 0, t, long, &meta("")).unwrap();
let d = &list(&doc, 0)[t];
let r = rect(d);
assert_eq!((r[0], r[2], r[3]), (100.0, 304.0, 692.0), "width and top edge stay");
let want = expected_box(692.0, 204.0, long, 12.0, 2.0);
assert_eq!(r[1], want[1], "the height fits the wrapped lines");
// The appearance covers the new rectangle, and every wrapped line is drawn.
let n = d.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let bbox: Vec<f64> = doc.get(n).as_dict().unwrap().get(b"BBox").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(bbox, r);
assert_eq!(ap_content(&doc, d).matches(" Tj").count(), appearance::wrap(long, 12.0, 204.0 - 2.0 * 4.0).len(), "nothing is clipped");
// Shorter text shrinks the box back to one line.
set_contents(&mut doc, 0, t, "Hi", &meta("")).unwrap();
let r = rect(&list(&doc, 0)[t]);
assert_eq!(r, [100.0, expected_box(692.0, 204.0, "Hi", 12.0, 2.0)[1], 304.0, 692.0]);
}
#[test]
fn editing_a_callout_refits_the_box_and_keeps_the_leader() {
let mut doc = fixture();
let c = add_annotation(
&mut doc,
&new(0, Shape::Callout { rect: [300.0, 600.0, 500.0, 612.0], knee: [260.0, 560.0], point: [200.0, 520.0], font_size: 12.0 }),
&meta("c"),
)
.unwrap();
let long = "the quick brown fox jumps over the lazy dog and keeps going";
set_contents(&mut doc, 0, c, long, &meta("")).unwrap();
let d = &list(&doc, 0)[c];
let outer = rect(d);
let f = |k: &[u8]| -> Vec<f64> { d.get(k).unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect() };
let cl = f(b"CL");
// The leader line keeps its start and knee; the attach point follows the re-fitted box.
assert_eq!(&cl[..4], &[200.0, 520.0, 260.0, 560.0]);
let want = expected_box(612.0, 200.0, long, 12.0, 2.0);
assert_eq!((cl[4], cl[5]), callout_attach([300.0, want[1], 500.0, 612.0], [260.0, 560.0]).into());
// The text box (recovered from /Rect and /RD) keeps its width and top edge.
let tb = [outer[0] + f(b"RD")[0], outer[1] + f(b"RD")[1], outer[2] - f(b"RD")[2], outer[3] - f(b"RD")[3]];
assert_eq!((tb[0], tb[2], tb[3]), (300.0, 500.0, 612.0));
assert_eq!(tb[1], want[1]);
// /Rect still grows to hold the leader line: its padded bounds are [186, 506, 274, 574].
assert_eq!((outer[0], outer[2], outer[3]), (186.0, 500.0, 612.0));
assert_eq!(ap_content(&doc, d).matches(" Tj").count(), appearance::wrap(long, 12.0, 200.0 - 2.0 * 4.0).len(), "nothing is clipped");
}
#[test]
fn a_locked_text_box_still_refits_its_text() {
let mut doc = fixture();
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 304.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap();
set_locked(&mut doc, 0, t, true).unwrap();
// Resizing is refused, but the text (and its re-fit) stays editable, as in Acrobat.
assert!(matches!(set_rect(&mut doc, 0, t, [0.0, 0.0, 50.0, 50.0], &meta("")), Err(AnnotError::Invalid(_))));
let long = "the quick brown fox jumps over the lazy dog";
set_contents(&mut doc, 0, t, long, &meta("")).unwrap();
let r = rect(&list(&doc, 0)[t]);
assert_eq!(r[3], 692.0);
let want = expected_box(692.0, 204.0, long, 12.0, 2.0);
assert_eq!(r[1], want[1], "the box grew to fit the wrapped lines");
}
#[test]
fn unknown_keys_survive_edits() {
let mut doc = fixture();
+11
View File
@@ -417,6 +417,17 @@ fn comments_through_tools() {
assert_eq!(undo["undone"], "Edit comment");
ok(&mut a, "edit_redo", json!({ "doc": doc }));
// Editing a text box re-fits its rectangle to the new text: the wrap width and top edge
// stay, the height follows the wrapped lines.
let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
let long = "the quick brown fox jumps over the lazy dog ".repeat(3);
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() }));
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
let r: Vec<f64> = tb["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert_eq!((r[0], r[1], r[2] - r[0]), (10.0, 200.0, 180.0), "width and top edge stay: {r:?}");
assert!(r[3] - r[1] > 40.0, "the box grew to fit the wrapped lines: {r:?}");
// The rendered page shows the rectangle's border.
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
assert!(matches!(png[0], Content::Png { .. }));
+101 -1
View File
@@ -254,6 +254,80 @@ fn text_page(content: &str) -> Document {
Document::open(Arc::new(out)).unwrap()
}
fn descriptor_text_page() -> Document {
let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET";
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+NeutralFace /FirstChar 97 /LastChar 98 /Widths [500 500] /FontDescriptor 6 0 R /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /FontDescriptor /FontName /NeutralFace /Flags 262208 /ItalicAngle -12 /FontWeight 700 /Ascent 900 /Descent -250 >>".into(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
#[test]
fn japanese_line_uses_unicode_type3_fallback() {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let replacement = "25362738こんにちは、お元気ですか? 2.3444";
let result = text::replace_line(&mut doc, 0, 0, replacement).unwrap();
assert_eq!(result.substituted.as_deref(), Some("Shippori Mincho Type3"));
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/PCJp"), "{content}");
let reopened = reopen(&doc);
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, replacement);
}
#[test]
fn japanese_paragraph_uses_unicode_type3_fallback() {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let replacement = "こんにちは、お元気ですか? 2.3444 日本語の文章";
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
let reopened = reopen(&doc);
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
}
#[test]
fn font_descriptor_style_is_exposed_even_with_a_neutral_name() {
let doc = descriptor_text_page();
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert!(lines[0].bold && lines[0].italic, "{:?}", lines[0]);
let blocks = text::text_blocks(&doc, 0).unwrap();
assert!(blocks[0].bold && blocks[0].italic, "{:?}", blocks[0]);
}
#[test]
fn mixed_font_runs_are_not_merged_into_one_source_style() {
let doc = text_page("BT /F1 12 Tf 72 700 Td (Regular) Tj /F2 12 Tf 150 700 Td (Subset) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 2);
assert_ne!(lines[0].font, lines[1].font);
}
#[test]
fn missing_glyphs_preserve_source_style_in_fallback() {
let mut doc = descriptor_text_page();
let result = text::replace_line(&mut doc, 0, 0, "Styled €").unwrap();
assert_eq!(result.substituted.as_deref(), Some("Helvetica-BoldOblique"));
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/PCEdHelveticaBoldOblique 12 Tf"), "{content}");
}
#[test]
fn text_lines_are_found_and_replaced_in_place() {
let mut doc =
@@ -264,7 +338,6 @@ fn text_lines_are_found_and_replaced_in_place() {
assert!((lines[0].rect[0] - 72.0).abs() < 0.01 && lines[0].rect[1] < 700.0 && lines[0].rect[3] > 700.0, "{:?}", lines[0].rect);
assert!((lines[0].size - 12.0).abs() < 1e-9 && lines[0].base_font == "Helvetica");
let second = lines[1].rect;
// Same font: reused; the next line stays where it was.
let r = text::replace_line(&mut doc, 0, 0, "Goodbye, café").unwrap();
assert_eq!(r.substituted, None);
let doc = reopen(&doc);
@@ -549,3 +622,30 @@ fn justify_underline_and_spacing() {
let w2 = text::text_lines(&doc, 0).unwrap()[0].rect;
assert!(((w2[2] - w2[0]) - (w0[2] - w0[0]) / 2.0).abs() < 1.0, "{w0:?} → {w2:?}");
}
#[test]
fn a_line_drawn_twice_is_replaced_everywhere() {
// Fake bold: a line drawn twice at the same spot (each copy its own text object). The two
// copies group apart, but editing one replaces both — a surviving copy would show the old
// text under the new.
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj ET BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2, "the copies group apart");
text::replace_block(&mut doc, 0, 0, "Hello world").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hello world"], "one line, replaced");
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert_eq!(content.matches("(Hello").count(), 1, "the second copy's operators are gone: {content}");
// Copies in one text object (the matrix re-issued at the same spot) go too.
let mut doc = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hi) Tj 1 0 0 1 72.3 700 Tm (Hi) Tj ET");
text::replace_line(&mut doc, 0, 0, "Hey").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hey"], "one line, replaced");
// A neighbouring line is not coincident and stays (40 pt spacing groups apart at 12 pt).
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj 0 -40 Td (world) Tj ET");
text::replace_block(&mut doc, 0, 0, "Hello there").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hello there", "world"]);
}
+271 -32
View File
@@ -16,6 +16,7 @@ use std::rc::Rc;
use printcraft_content::{Matrix, Op, parse, serialize_ops};
use printcraft_cos::{Dict, Document, Object, PdfString, Stream};
use printcraft_fonts::pdf::Metrics;
use printcraft_fonts::{GlyphError, shippori_glyph};
use crate::EditError;
@@ -30,6 +31,10 @@ pub struct TextLine {
pub font: String,
pub base_font: String,
pub size: f64,
pub bold: bool,
pub italic: bool,
/// The current fill colour as RGB.
pub color: [f64; 3],
/// Whether new text in this font can only be shown by substituting another font (no
/// Unicode mapping for the line, so nothing could be reused).
pub decodable: bool,
@@ -104,6 +109,11 @@ pub struct TextBlock {
pub rect: [f64; 4],
pub base_font: String,
pub size: f64,
pub bold: bool,
pub italic: bool,
/// The text fill colour as RGB, used by the visual editor and preserved when no colour
/// override is requested.
pub color: [f64; 3],
/// Indexes into [`text_lines`].
pub lines: Vec<usize>,
}
@@ -159,6 +169,31 @@ fn page_dict(doc: &Document, page: usize) -> Result<printcraft_model::Page, Edit
printcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
}
fn fill_color(fill: &[Op]) -> [f64; 3] {
let Some(op) = fill.iter().rev().find(|op| matches!(op.op.as_slice(), b"g" | b"rg" | b"k")) else {
return [0.0, 0.0, 0.0];
};
match op.op.as_slice() {
b"g" => {
let v = op.operands.first().and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0);
[v, v, v]
}
b"rg" => [
op.operands.first().and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0),
op.operands.get(1).and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0),
op.operands.get(2).and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0),
],
b"k" => {
let c = op.operands.first().and_then(Object::as_f64).unwrap_or(0.0);
let m = op.operands.get(1).and_then(Object::as_f64).unwrap_or(0.0);
let y = op.operands.get(2).and_then(Object::as_f64).unwrap_or(0.0);
let k = op.operands.get(3).and_then(Object::as_f64).unwrap_or(0.0);
[(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]
}
_ => [0.0, 0.0, 0.0],
}
}
/// The text-showing operators of one stream with their text, font, box and baseline.
struct Shown {
op: usize,
@@ -177,6 +212,8 @@ struct Shown {
font: Vec<u8>,
base_font: String,
size: f64,
bold: bool,
italic: bool,
decodable: bool,
}
@@ -333,6 +370,8 @@ fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap<V
font: name,
base_font: m.base_font.clone(),
size: if size_user > 0.0 { size_user } else { ts.size },
bold: m.bold,
italic: m.italic,
decodable,
});
}
@@ -355,7 +394,14 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
let mut last: Option<(usize, f64, f64, f64)> = None; // (bt, baseline, end_x, size)
for s in shown {
let joins = last.is_some_and(|(bt, base, end, size)| {
bt == s.bt && (s.baseline - base).abs() < size * 0.3 && s.start_x > end - size && s.start_x - end < size * 3.0
bt == s.bt
&& lines.last().is_some_and(|l| l.font.as_bytes() == s.font.as_slice())
&& lines.last().is_some_and(|l| {
(s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color
})
&& (s.baseline - base).abs() < size * 0.3
&& s.start_x > end - size
&& s.start_x - end < size * 3.0
});
if joins && let Some(l) = lines.last_mut() {
let gap = s.start_x - last.map_or(s.start_x, |x| x.2);
@@ -366,6 +412,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
l.ops.push(s.op);
l.decodable &= s.decodable;
l.color = fill_color(&s.state.fill);
} else {
lines.push(TextLine {
text: s.text.clone(),
@@ -373,6 +420,9 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
font: String::from_utf8_lossy(&s.font).into_owned(),
base_font: s.base_font.clone(),
size: s.size,
bold: s.bold,
italic: s.italic,
color: fill_color(&s.state.fill),
decodable: s.decodable,
stream: si,
ops: vec![s.op],
@@ -399,9 +449,133 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
/// Text → the bytes that show it in the chosen font.
type Encoder = Box<dyn Fn(&str) -> Option<Vec<u8>>>;
/// The substitute font's resource name.
fn is_win_ansi_char(c: char) -> bool {
matches!(c, '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' | '€' | '‚' | '„' | '…' | '‘' | '’' | '“' | '”' | '•' | '–' | '—' | '™' | '\t')
}
fn needs_type3(text: &str) -> bool {
text.chars().any(|c| !is_win_ansi_char(c))
}
fn source_family(base_font: &str) -> crate::added::Family {
let name = base_font.to_ascii_lowercase();
if ["courier", "mono", "consolas", "menlo", "monaco", "lucida console"].iter().any(|s| name.contains(s)) {
crate::added::Family::Courier
} else if !name.contains("sans") && ["times", "serif", "roman", "cambria", "georgia", "palatino", "garamond"].iter().any(|s| name.contains(s)) {
crate::added::Family::Times
} else {
crate::added::Family::Helvetica
}
}
const SUBSTITUTE_NAME: &str = "PCEdHelv";
const SUBSTITUTE: &[u8] = SUBSTITUTE_NAME.as_bytes();
const MAX_TYPE3_GLYPHS: usize = 240;
#[derive(Clone)]
struct Type3Fallback {
name: String,
codes: Vec<(char, u8, f64)>,
}
fn pdf_num(v: f64) -> String {
if v.fract() == 0.0 { format!("{v:.0}") } else { format!("{v:.4}").trim_end_matches('0').trim_end_matches('.').to_string() }
}
fn type3_path(ch: char) -> Result<(Vec<u8>, f64), EditError> {
let glyph = shippori_glyph(ch).map_err(|e| match e {
GlyphError::Missing => EditError::Invalid(format!("Japanese fallback font has no glyph for U+{:04X}", ch as u32)),
GlyphError::TooComplex => EditError::Invalid(format!("Japanese fallback glyph U+{:04X} is too complex", ch as u32)),
})?;
let scale = 1000.0;
let mut out = format!("{} 0 0 0 0 1000 1000 d1\n", pdf_num(glyph.width * scale)).into_bytes();
for contour in glyph.contours {
let Some(first) = contour.first() else { continue };
out.extend_from_slice(format!("{} {} m\n", pdf_num(first[0] * scale), pdf_num(first[1] * scale)).as_bytes());
for p in contour.iter().skip(1) {
out.extend_from_slice(format!("{} {} l\n", pdf_num(p[0] * scale), pdf_num(p[1] * scale)).as_bytes());
}
out.extend_from_slice(b"h\n");
}
out.extend_from_slice(b"f\n");
Ok((out, glyph.width))
}
fn unicode_hex(ch: char) -> String {
let mut units = [0u16; 2];
let encoded = ch.encode_utf16(&mut units);
encoded.iter().map(|u| format!("{u:04X}")).collect()
}
fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str) -> Result<Type3Fallback, EditError> {
let mut chars = Vec::new();
for ch in text.chars() {
if !chars.contains(&ch) {
if chars.len() >= MAX_TYPE3_GLYPHS {
return Err(EditError::Invalid("Japanese replacement has too many unique characters".into()));
}
chars.push(ch);
}
}
if chars.is_empty() {
return Err(EditError::Invalid("replacement text is empty".into()));
}
let mut codes = Vec::with_capacity(chars.len());
let mut charprocs = Dict::new();
let mut widths = Vec::with_capacity(chars.len());
let mut differences = vec![Object::Int(1)];
let mut cmap = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
);
cmap.push_str(&format!("{} beginbfchar\n", chars.len()));
for (i, ch) in chars.into_iter().enumerate() {
let code = u8::try_from(i + 1).map_err(|_| EditError::Invalid("Japanese replacement has too many unique characters".into()))?;
let glyph_name = format!("g{code:02X}");
let (path, width) = type3_path(ch)?;
let mut pd = Dict::new();
pd.set(b"Length".to_vec(), path.len() as i64);
let proc_ref = doc.add(Object::Stream(Stream::from_raw(pd, path)));
charprocs.set(glyph_name.as_bytes().to_vec(), Object::Ref(proc_ref));
differences.push(Object::name(&glyph_name));
widths.push(Object::Real((width * 1000.0).round()));
cmap.push_str(&format!("<{code:02X}> <{}>\n", unicode_hex(ch)));
codes.push((ch, code, width));
}
cmap.push_str("endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
let mut cmap_dict = Dict::new();
cmap_dict.set(b"Length".to_vec(), cmap.len() as i64);
let cmap_ref = doc.add(Object::Stream(Stream::from_raw(cmap_dict, cmap.into_bytes())));
let mut encoding = Dict::new();
encoding.set(b"Type".to_vec(), Object::name("Encoding"));
encoding.set(b"Differences".to_vec(), Object::Array(differences));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type3"));
font.set(b"Name".to_vec(), Object::name("PCJapanese"));
font.set(b"FontBBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)]));
font.set(
b"FontMatrix".to_vec(),
Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]),
);
font.set(b"FirstChar".to_vec(), Object::Int(1));
font.set(b"LastChar".to_vec(), Object::Int(codes.len() as i64));
font.set(b"Widths".to_vec(), Object::Array(widths));
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
font.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
font.set(b"ToUnicode".to_vec(), Object::Ref(cmap_ref));
let mut name = String::from("PCJp");
let mut suffix = 0usize;
while fonts_res.contains(name.as_bytes()) {
suffix = suffix.saturating_add(1);
name = format!("PCJp{suffix}");
}
fonts_res.set(name.as_bytes().to_vec(), Object::Dict(font));
Ok(Type3Fallback { name, codes })
}
fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<u8>> {
text.chars().map(|ch| fallback.codes.iter().find(|(c, _, _)| *c == ch).map(|(_, code, _)| *code)).collect()
}
/// Replace the text of line `line` (an index into [`text_lines`]) on `page` with `text`.
pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) -> Result<LineEdit, EditError> {
@@ -433,33 +607,48 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) ->
match reused {
Some(bytes) => replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])),
None => {
let win = printcraft_fonts::win_ansi(&text);
// WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing.
let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
if text.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font)));
if needs_type3(&text) {
let fallback = type3_font(doc, &mut fonts_res, &text)?;
let bytes = type3_encode(&fallback, &text)
.ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the Japanese fallback")))?;
let size = font_size_before(&ops, first).unwrap_or(target.size);
replacement.push(Op::new("Tf", vec![Object::name(&fallback.name), printcraft_content::num(size)]));
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
replacement.push(Op::new("Tf", vec![Object::name(&target.font), printcraft_content::num(size)]));
substituted = Some("Shippori Mincho Type3".into());
} else {
let win = printcraft_fonts::win_ansi(&text);
// WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing.
let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
if text.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font)));
}
// The size in text space: the current Tf's size.
let size = font_size_before(&ops, first).unwrap_or(target.size);
let family = source_family(&target.base_font);
let base = family.base_font(target.bold, target.italic);
let substitute_name = format!("PCEd{}", base.replace('-', ""));
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), printcraft_content::num(size)]));
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))]));
replacement.push(Op::new("Tf", vec![Object::name(&target.font), printcraft_content::num(size)]));
substituted = Some(base.to_string());
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name(base));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
fonts_res.set(substitute_name.into_bytes(), Object::Dict(f));
}
// The size in text space: the current Tf's size.
let size = font_size_before(&ops, first).unwrap_or(target.size);
replacement.push(Op::new("Tf", vec![Object::name(SUBSTITUTE_NAME), printcraft_content::num(size)]));
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))]));
replacement.push(Op::new("Tf", vec![Object::name(&target.font), printcraft_content::num(size)]));
substituted = Some("Helvetica".to_string());
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
fonts_res.set(SUBSTITUTE.to_vec(), Object::Dict(f));
}
}
// Rebuild: the line's first operator becomes the replacement, its others go.
let drop: std::collections::HashSet<usize> = target.ops[1..].iter().copied().collect();
// Rebuild: the line's first operator becomes the replacement, its others go. Copies of the
// line drawn in the same area go too, so the replacement is all that shows.
let drops = coincident_ops(&lines, std::slice::from_ref(&target.rect));
let mut new_ops = Vec::with_capacity(ops.len() + replacement.len());
for (i, op) in ops.drain(..).enumerate() {
if i == first {
new_ops.append(&mut replacement);
} else if !drop.contains(&i) {
} else if !drops.get(&target.stream).is_some_and(|d| d.contains(&i)) {
new_ops.push(op);
}
}
@@ -488,6 +677,27 @@ fn font_size_before(ops: &[Op], at: usize) -> Option<f64> {
ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1))
}
/// The operators of every line drawn in the same area as one of `rects`, grouped by content
/// stream. Documents sometimes draw a line more than once (fake bold, an invisible text layer),
/// and a surviving copy would show the old text under the replaced line. Copies overlap a
/// member's rect by more than half of the smaller rect; neighbouring lines share no area.
fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::HashMap<usize, std::collections::HashSet<usize>> {
let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0);
let mut drop = std::collections::HashMap::new();
for l in lines {
let covered = rects.iter().any(|m| {
let ix = (m[2].min(l.rect[2]) - m[0].max(l.rect[0])).max(0.0);
let iy = (m[3].min(l.rect[3]) - m[1].max(l.rect[1])).max(0.0);
let small = area(*m).min(area(l.rect));
small > 0.0 && ix * iy / small > 0.5
});
if covered {
drop.entry(l.stream).or_insert_with(std::collections::HashSet::new).extend(l.ops.iter().copied());
}
}
drop
}
/// The paragraphs on a page (0-based).
pub fn text_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, EditError> {
let lines = text_lines(doc, page)?;
@@ -512,6 +722,10 @@ fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
let g = prev.origin.baseline - l.origin.baseline;
prev.stream == l.stream
&& prev.font == l.font
&& prev.base_font == l.base_font
&& prev.bold == l.bold
&& prev.italic == l.italic
&& prev.color == l.color
&& (prev.size - l.size).abs() < 0.01
&& aligned(prev, l)
&& g > l.size * 0.8
@@ -534,7 +748,16 @@ fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
b.lines.push(i);
} else {
gap = None;
blocks.push(TextBlock { text: l.text.trim().to_string(), rect: l.rect, base_font: l.base_font.clone(), size: l.size, lines: vec![i] });
blocks.push(TextBlock {
text: l.text.trim().to_string(),
rect: l.rect,
base_font: l.base_font.clone(),
size: l.size,
bold: l.bold,
italic: l.italic,
color: l.color,
lines: vec![i],
});
}
}
blocks
@@ -615,8 +838,9 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
let o_state = ts_state.clone();
let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d));
let reuse = style.family.is_none() && metrics.as_ref().is_some_and(|m| m.encode(&text).is_some());
let type3 = if !reuse && needs_type3(&text) { Some(type3_font(doc, &mut fonts_res, &text)?) } else { None };
// The standard font used when the paragraph's own can't be (chosen, or substituted).
let (family, bold, italic) = style.family.unwrap_or((crate::added::Family::Helvetica, false, false));
let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic));
let std_width = move |s: &str, size: f64| -> f64 {
match family {
crate::added::Family::Courier => s.chars().count() as f64 * 0.6 * size,
@@ -641,6 +865,10 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
.sum::<f64>()
})
.unwrap_or(0.0),
_ if let Some(fallback) = &type3 => {
fallback.codes.iter().map(|(ch, _, width)| s.chars().filter(|c| c == ch).count() as f64 * width * size).sum::<f64>()
+ s.chars().count() as f64 * o_state.char_spacing
}
_ => std_width(s, size) + s.chars().count() as f64 * o_state.char_spacing,
};
t * o_state.scale * k
@@ -650,6 +878,11 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
let new_font = !reuse;
let (show_font, encode): (String, Encoder) = if let Some(m) = metrics.clone().filter(|_| reuse) {
(font_name.clone(), Box::new(move |s: &str| m.encode(s)))
} else if let Some(fallback) = type3.clone() {
let name = fallback.name.clone();
let encoder = fallback.clone();
substituted = Some("Shippori Mincho Type3".into());
(name, Box::new(move |s: &str| type3_encode(&encoder, s)))
} else {
let win = printcraft_fonts::win_ansi(&text);
let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect();
@@ -673,6 +906,7 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
let lead = match style.line_spacing {
Some(m) => size * m.clamp(0.5, 5.0),
None if members.len() > 1 => (members[0].origin.baseline - members[1].origin.baseline) / k * size / old_size,
None if o_state.leading > 0.0 => o_state.leading,
None => size * 1.2,
};
let n = printcraft_content::num;
@@ -746,16 +980,20 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
block_ops.push(Op::new("Q", vec![]));
}
block_ops.push(Op::new("Q", vec![]));
let drop: std::collections::HashSet<usize> = members.iter().flat_map(|l| l.ops.iter().copied()).collect();
// The paragraph's operators go, and so does anything drawn in the same area (a fake-bold
// second copy, an invisible text layer): the new text is all that may show.
let mut drop: std::collections::HashMap<usize, std::collections::HashSet<usize>> =
members.iter().flat_map(|l| [(l.stream, l.ops.iter().copied().collect::<std::collections::HashSet<_>>())]).collect();
let member_rects: Vec<[f64; 4]> = members.iter().map(|l| l.rect).collect();
for (stream, ops) in coincident_ops(&lines, &member_rects) {
drop.entry(stream).or_default().extend(ops);
}
// Text shown earlier in the same text object stays first: the paragraph then goes where it
// was, between two halves of the text object, so it keeps its place in reading order.
let start = first.ops.first().copied().unwrap_or(o.bt_op);
let split = ops
.get(o.bt_op..start)
.unwrap_or_default()
.iter()
.enumerate()
.any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i)));
let split = ops.get(o.bt_op..start).unwrap_or_default().iter().enumerate().any(|(i, op)| {
matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.get(&first.stream).is_some_and(|d| d.contains(&(o.bt_op + i)))
});
// What the text after the paragraph expects: the state at its BT (or where it was split).
let after = if split { &o.state } else { &o.bt_state };
block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some()));
@@ -770,7 +1008,8 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
if i == at {
new_ops.append(&mut block_ops);
}
if !drop.contains(&i) {
let keep = drop.get(&first.stream).is_none_or(|d| !d.contains(&i));
if keep {
new_ops.push(op);
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
mod encodings;
pub mod pdf;
mod script;
pub use script::{ScriptOutline, script_outline};
pub use script::{GlyphError, GlyphOutline, SHIPPORI_MINCHO, ScriptOutline, script_outline, shippori_glyph};
/// Approximate advance of `s` in Helvetica (or Arial) at `size` points.
pub fn helvetica_width(s: &str, size: f64) -> f64 {
+24 -3
View File
@@ -50,6 +50,10 @@ pub struct Metrics {
/// `/BaseFont`, and whether it is a subset (`ABCDEF+Name`: other glyphs are missing).
pub base_font: String,
pub subset: bool,
/// Whether the PDF identifies this face as bold or italic. These come from the
/// font name and, when present, `/FontDescriptor` flags/weight/angle.
pub bold: bool,
pub italic: bool,
/// Bytes per code for writing new text (1 for simple fonts, else the codespace length).
code_len: usize,
}
@@ -76,6 +80,15 @@ fn be(b: &[u8]) -> u32 {
b.iter().fold(0u32, |a, x| (a << 8) | u32::from(*x))
}
fn style_from_name(name: &str) -> (bool, bool) {
let name = name.to_ascii_lowercase();
let name = name.split_once('+').map_or(name.as_str(), |(_, n)| n);
(
["bold", "black", "heavy", "semibold", "demi"].iter().any(|s| name.contains(s)),
["italic", "oblique", "slanted"].iter().any(|s| name.contains(s)),
)
}
/// Codespace ranges and CID mappings from an embedded CMap stream.
#[allow(clippy::type_complexity)]
fn parse_cmap(data: &[u8]) -> (Vec<(usize, Vec<u8>, Vec<u8>)>, Vec<(u32, u32, u32)>) {
@@ -141,6 +154,8 @@ impl Metrics {
unicode: (32..127u8).map(|c| (u32::from(c), char::from(c).to_string())).collect(),
base_font: "Helvetica".into(),
subset: false,
bold: false,
italic: false,
code_len: 1,
}
}
@@ -149,6 +164,9 @@ impl Metrics {
let mut m = Self::read_metrics(doc, font);
let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).into_owned();
m.subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase());
let (bold, italic) = style_from_name(&base);
m.bold |= bold;
m.italic |= italic;
m.base_font = base;
m.code_len = match &m.codes {
Codes::One => 1,
@@ -226,9 +244,7 @@ impl Metrics {
m.descent = (bbox[1] * fm[3]).min(-0.1);
}
}
return m;
}
if m.widths.is_empty() {
} else if m.widths.is_empty() {
let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).to_ascii_lowercase();
m.std14 = Some(if base.contains("courier") {
Std14::Courier
@@ -242,6 +258,11 @@ impl Metrics {
}
}
if let Some(d) = descriptor {
let flags = d.get(b"Flags").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0).max(0.0) as u32;
let angle = d.get(b"ItalicAngle").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0);
let weight = d.get(b"FontWeight").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0);
m.italic |= flags & 64 != 0 || angle.abs() > 0.1;
m.bold |= flags & 262_144 != 0 || weight >= 600.0;
let a = d.get(b"Ascent").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) / 1000.0;
let de = d.get(b"Descent").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) / 1000.0;
// Fonts often claim 0; never shrink the glyph box below a sensible minimum.
+103 -19
View File
@@ -1,5 +1,4 @@
//! Typed signatures: a name in the bundled script font (Dancing Script, OFL-1.1) as glyph
//! outlines, flattened to polygons. Drawn as filled paths, so no font is embedded in the PDF.
//! Typed signatures and bounded glyph outlines from approved bundled fonts.
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
@@ -7,8 +6,10 @@ use skrifa::{FontRef, MetadataProvider};
static FONT: &[u8] = include_bytes!("../../../assets/fonts/DancingScript.ttf");
/// Outlines of a line of text at a font size of 1 (em units): y up, the baseline at 0, starting
/// at x = 0. Fill them with the even-odd (or nonzero) rule.
/// The approved Japanese fallback face used by the editor and generated Type 3 fonts.
pub static SHIPPORI_MINCHO: &[u8] = include_bytes!("../../../assets/fonts/ShipporiMincho-Regular.ttf");
/// Outlines of a line of existing text at a font size of 1 (em units).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ScriptOutline {
pub contours: Vec<Vec<[f64; 2]>>,
@@ -17,62 +18,136 @@ pub struct ScriptOutline {
pub descent: f64,
}
/// One glyph outline in em units, with the baseline at y = 0.
#[derive(Clone, Debug, PartialEq)]
pub struct GlyphOutline {
pub contours: Vec<Vec<[f64; 2]>>,
pub width: f64,
pub bbox: [f64; 4],
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GlyphError {
Missing,
TooComplex,
}
struct Flatten {
contours: Vec<Vec<[f64; 2]>>,
cur: Vec<[f64; 2]>,
scale: f64,
dx: f64,
points: usize,
too_complex: bool,
}
impl Flatten {
fn new(scale: f64) -> Self {
Self { contours: Vec::new(), cur: Vec::new(), scale, dx: 0.0, points: 0, too_complex: false }
}
fn pt(&self, x: f32, y: f32) -> [f64; 2] {
[self.dx + x as f64 * self.scale, y as f64 * self.scale]
}
fn last(&self) -> [f64; 2] {
self.cur.last().copied().unwrap_or([self.dx, 0.0])
}
fn push(&mut self, p: [f64; 2]) {
const MAX_POINTS: usize = 4096;
if self.points >= MAX_POINTS {
self.too_complex = true;
return;
}
self.points += 1;
self.cur.push(p);
}
fn close(&mut self) {
if self.cur.len() > 2 {
self.contours.push(std::mem::take(&mut self.cur));
} else {
self.cur.clear();
}
}
}
const STEPS: usize = 8;
impl OutlinePen for Flatten {
fn move_to(&mut self, x: f32, y: f32) {
if self.cur.len() > 2 {
self.contours.push(std::mem::take(&mut self.cur));
}
self.cur.clear();
let p = self.pt(x, y);
self.cur.push(p);
self.close();
self.push(self.pt(x, y));
}
fn line_to(&mut self, x: f32, y: f32) {
let p = self.pt(x, y);
self.cur.push(p);
self.push(self.pt(x, y));
}
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
let (p0, c, p) = (self.last(), self.pt(cx, cy), self.pt(x, y));
for i in 1..=STEPS {
let t = i as f64 / STEPS as f64;
let u = 1.0 - t;
self.cur.push([u * u * p0[0] + 2.0 * u * t * c[0] + t * t * p[0], u * u * p0[1] + 2.0 * u * t * c[1] + t * t * p[1]]);
self.push([u * u * p0[0] + 2.0 * u * t * c[0] + t * t * p[0], u * u * p0[1] + 2.0 * u * t * c[1] + t * t * p[1]]);
}
}
fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
let (p0, c0, c1, p) = (self.last(), self.pt(c0x, c0y), self.pt(c1x, c1y), self.pt(x, y));
for i in 1..=STEPS {
let t = i as f64 / STEPS as f64;
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
self.cur.push([a * p0[0] + b * c0[0] + c * c1[0] + d * p[0], a * p0[1] + b * c0[1] + c * c1[1] + d * p[1]]);
self.push([a * p0[0] + b * c0[0] + c * c1[0] + d * p[0], a * p0[1] + b * c0[1] + c * c1[1] + d * p[1]]);
}
}
fn close(&mut self) {
if self.cur.len() > 2 {
self.contours.push(std::mem::take(&mut self.cur));
}
self.cur.clear();
Flatten::close(self);
}
}
/// Return one Shippori Mincho glyph, bounded so hostile replacement text cannot allocate
/// unbounded outline data.
pub fn shippori_glyph(ch: char) -> Result<GlyphOutline, GlyphError> {
let Ok(font) = FontRef::new(SHIPPORI_MINCHO) else { return Err(GlyphError::Missing) };
let loc = LocationRef::default();
let metrics = font.metrics(Size::unscaled(), loc);
let scale = 1.0 / metrics.units_per_em.max(1) as f64;
let Some(gid) = font.charmap().map(ch) else { return Err(GlyphError::Missing) };
let advances = font.glyph_metrics(Size::unscaled(), loc);
let width = advances.advance_width(gid).unwrap_or(0.0) as f64 * scale;
if !width.is_finite() || width <= 0.0 || width > 2.0 {
return Err(GlyphError::Missing);
}
let Some(glyph) = font.outline_glyphs().get(gid) else { return Err(GlyphError::Missing) };
let mut pen = Flatten::new(scale);
let _ = glyph.draw(DrawSettings::unhinted(Size::unscaled(), loc), &mut pen);
pen.close();
if pen.too_complex || pen.contours.len() > 256 {
return Err(GlyphError::TooComplex);
}
let mut bbox = [0.0, 0.0, width, 0.0];
let mut any = false;
for p in pen.contours.iter().flatten() {
if !p[0].is_finite() || !p[1].is_finite() {
return Err(GlyphError::Missing);
}
if !any {
bbox = [p[0], p[1], p[0], p[1]];
any = true;
} else {
bbox[0] = bbox[0].min(p[0]);
bbox[1] = bbox[1].min(p[1]);
bbox[2] = bbox[2].max(p[0]);
bbox[3] = bbox[3].max(p[1]);
}
}
Ok(GlyphOutline { contours: pen.contours, width, bbox })
}
/// The outlines of `text` in the script font (characters it lacks are skipped).
pub fn script_outline(text: &str) -> ScriptOutline {
let Ok(font) = FontRef::new(FONT) else { return ScriptOutline::default() };
@@ -82,7 +157,7 @@ pub fn script_outline(text: &str) -> ScriptOutline {
let charmap = font.charmap();
let glyphs = font.outline_glyphs();
let advances = font.glyph_metrics(Size::unscaled(), loc);
let mut pen = Flatten { contours: Vec::new(), cur: Vec::new(), scale, dx: 0.0 };
let mut pen = Flatten::new(scale);
for ch in text.chars() {
let Some(gid) = charmap.map(ch) else { continue };
if let Some(g) = glyphs.get(gid) {
@@ -91,6 +166,7 @@ pub fn script_outline(text: &str) -> ScriptOutline {
}
pen.dx += advances.advance_width(gid).unwrap_or(0.0) as f64 * scale;
}
pen.close();
ScriptOutline { contours: pen.contours, width: pen.dx, ascent: metrics.ascent as f64 * scale, descent: metrics.descent as f64 * scale }
}
@@ -106,4 +182,12 @@ mod tests {
assert!(max_x <= o.width + 0.2);
assert!(super::script_outline("").contours.is_empty());
}
#[test]
fn shippori_resolves_japanese_glyphs() {
let g = super::shippori_glyph('こ').expect("bundled font has Japanese glyph");
assert!(g.width > 0.2 && g.width < 2.0);
assert!(!g.contours.is_empty());
assert_eq!(super::shippori_glyph('\u{1f4a9}'), Err(super::GlyphError::Missing));
}
}
+54 -6
View File
@@ -152,6 +152,21 @@ impl Arg {
/// than recursed into (a hostile file could otherwise overflow the stack).
const MAX_NESTING: usize = 64;
fn hex4(bytes: &[u8]) -> Option<u16> {
if bytes.len() != 4 {
return None;
}
bytes.iter().try_fold(0u16, |value, byte| {
let digit = match byte {
b'0'..=b'9' => byte - b'0',
b'a'..=b'f' => byte - b'a' + 10,
b'A'..=b'F' => byte - b'A' + 10,
_ => return None,
};
value.checked_mul(16)?.checked_add(u16::from(digit))
})
}
struct Parser<'a> {
s: &'a [u8],
i: usize,
@@ -176,11 +191,30 @@ impl Parser<'_> {
b'\\' if self.i < self.s.len() => {
let e = self.s[self.i];
self.i += 1;
out.push(match e {
b'n' => b'\n',
b't' => b'\t',
other => other,
});
match e {
b'n' => out.push(b'\n'),
b'r' => out.push(b'\r'),
b't' => out.push(b'\t'),
b'u' => {
let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?;
self.i += 4;
let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") {
let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?;
if (0xDC00..=0xDFFF).contains(&low) {
self.i += 6;
0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00)
} else {
u32::from(code)
}
} else {
u32::from(code)
};
let ch = char::from_u32(code).unwrap_or('\u{FFFD}');
let mut encoded = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes());
}
other => out.push(other),
}
}
c if c == q => return Some(String::from_utf8_lossy(&out).into_owned()),
c => out.push(c),
@@ -364,7 +398,15 @@ fn js_str(s: &str) -> String {
o.push(c);
}
'\n' => o.push_str("\\n"),
c => o.push(c),
'\r' => o.push_str("\\r"),
'\t' => o.push_str("\\t"),
c if c.is_ascii() && !c.is_control() => o.push(c),
c => {
let mut units = [0u16; 2];
for unit in c.encode_utf16(&mut units) {
o.push_str(&format!("\\u{unit:04X}"));
}
}
}
}
o.push('"');
@@ -955,6 +997,12 @@ mod tests {
let (fj, _) = format_js(&f).unwrap();
assert_eq!(parse_format(&fj), Some(f));
}
let euro = format_js(&Format::Number { decimals: 2, sep: 2, neg: 0, currency: "€".into(), prepend: true }).unwrap().0;
assert!(euro.contains("\\u20AC"), "{euro}");
assert!(!euro.contains('€'), "{euro}");
let pound = format_js(&Format::Number { decimals: 2, sep: 0, neg: 0, currency: "£".into(), prepend: true }).unwrap().0;
assert!(pound.contains("\\u00A3"), "{pound}");
assert!(!pound.contains('£'), "{pound}");
let c = Calculate::Simple { op: CalcOp::Product, fields: vec!["q".into(), "p".into()] };
assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c));
let c = Calculate::Notation("a + b".into());
+1 -1
View File
@@ -1526,7 +1526,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) {
view.pending_edit = Some(e);
}
if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view) {
if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) {
view.pending_edit = Some(e);
}
if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, today) {
+119 -23
View File
@@ -3,7 +3,7 @@
//! box, Esc cancels). Click an image to select it: drag to move, drag a corner to resize (keeping
//! its proportions), right-click for rotate, flip, replace, save and delete; Delete removes it.
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
use egui::{Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke};
use printcraft_engine::Edit;
use printcraft_render::DocInfo;
@@ -19,6 +19,14 @@ pub struct LineEditor {
pub text: String,
original: String,
rect: Rect,
/// The original PDF rectangle, in user space. It is converted again each frame so the editor
/// stays attached while the page is zoomed, scrolled, or rotated.
source_rect: [f32; 4],
multiline: bool,
/// How far the editor box may grow to the right (screen pixels): a single-line paragraph
/// rewraps growing to the page's edge, a multi-line one keeps its width.
max_width: f32,
/// Current screen-space size. Recomputed from the current page transform before painting.
size: f32,
focus: bool,
/// The Format text panel's values, and what the paragraph had (to send only changes).
@@ -71,6 +79,12 @@ pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool {
}
impl LineEditor {
/// How far the box may grow to the right, when the paragraph is a single line (a multi-line
/// paragraph rewraps to its own width and the box doesn't grow).
pub fn growth(&self) -> Option<f32> {
(self.max_width > self.rect.width()).then_some(self.max_width)
}
/// The formatting the panel changed.
pub fn style(&self) -> printcraft_engine::BlockStyle {
let (l, o) = (&self.look, &self.look0);
@@ -94,27 +108,56 @@ impl LineEditor {
self.original = self.text.clone();
self.focus = true;
}
/// Adopt the rewritten paragraph's current geometry before the next overlay frame.
pub(crate) fn refresh_source(&mut self, block: &printcraft_engine::TextBlock) {
self.source_rect = block.rect.map(|v| v as f32);
self.multiline = block.lines.len() > 1;
}
}
/// The look shown for a paragraph: the family and weight guessed from its font's name.
fn look_of(b: &printcraft_engine::TextBlock) -> printcraft_engine::AddedText {
/// The look shown for a paragraph: the family and weight guessed from its PDF font name.
fn source_look(base_font: &str, size: f64, color: [f64; 3], detected_bold: bool, detected_italic: bool) -> printcraft_engine::AddedText {
use printcraft_engine::FontFamily as F;
let name = b.base_font.to_ascii_lowercase();
let name = base_font.to_ascii_lowercase();
let family = if ["courier", "mono", "consolas", "menlo", "monaco", "lucida console"].iter().any(|s| name.contains(s)) {
F::Courier
} else if !name.contains("sans") && ["times", "serif", "roman", "cambria", "georgia", "palatino", "garamond"].iter().any(|s| name.contains(s)) {
F::Times
} else {
F::Helvetica
};
printcraft_engine::AddedText {
family: if name.contains("times") || name.contains("serif") && !name.contains("sans") {
F::Times
} else if name.contains("courier") || name.contains("mono") {
F::Courier
} else {
F::Helvetica
},
bold: name.contains("bold") || name.contains("black") || name.contains("heavy"),
italic: name.contains("italic") || name.contains("oblique"),
size: (b.size * 10.0).round() / 10.0,
family,
bold: detected_bold || ["bold", "black", "heavy", "semibold", "demi"].iter().any(|s| name.contains(s)),
italic: detected_italic || ["italic", "oblique", "slanted"].iter().any(|s| name.contains(s)),
size: (size * 10.0).round() / 10.0,
color,
..Default::default()
}
}
fn look_of(b: &printcraft_engine::TextBlock) -> printcraft_engine::AddedText {
source_look(&b.base_font, b.size, b.color, b.bold, b.italic)
}
fn editor_font(look: &printcraft_engine::AddedText, size: f32) -> FontId {
let family = match (look.family, look.bold) {
(printcraft_engine::FontFamily::Courier, _) => FontFamily::Monospace,
(_, true) => FontFamily::Name("semibold".into()),
(_, false) => FontFamily::Proportional,
};
FontId::new(size, family)
}
fn color32(color: [f64; 3]) -> Color32 {
Color32::from_rgb(
(color[0].clamp(0.0, 1.0) * 255.0).round() as u8,
(color[1].clamp(0.0, 1.0) * 255.0).round() as u8,
(color[2].clamp(0.0, 1.0) * 255.0).round() as u8,
)
}
/// A selected page image, and what the pointer is doing to it.
#[derive(Clone, Debug, PartialEq)]
pub struct ImageSelection {
@@ -157,6 +200,21 @@ pub(crate) fn image_input(
}
let pointer = ui.input(|i| i.pointer.hover_pos());
let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len());
for (i, b) in boxes.iter().enumerate() {
let selected_box = selected == Some(i);
let hovered = ui.input(|inp| inp.pointer.hover_pos()).is_some_and(|p| b.contains(p));
let stroke = if selected_box {
Stroke::new(1.5, ACCENT)
} else if hovered {
Stroke::new(1.5, ACCENT.gamma_multiply(0.7))
} else {
Stroke::new(0.75, ACCENT.gamma_multiply(0.35))
};
if selected_box {
painter.rect_filled(*b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04));
}
painter.rect_stroke(*b, CornerRadius::ZERO, stroke, egui::StrokeKind::Outside);
}
// The selected image: frame, corner handles, dragging.
if let Some(i) = selected {
let b = boxes[i];
@@ -272,23 +330,38 @@ pub(crate) fn page_input(
) -> bool {
let boxes: Vec<Rect> = lines.iter().map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)).collect();
let painter = ui.painter();
for b in &boxes {
painter.rect_stroke(*b, CornerRadius::same(2), Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
let active = view.line_editor.as_ref().filter(|e| e.page == page).map(|e| e.block);
for (i, b) in boxes.iter().enumerate() {
if active == Some(i) {
painter.rect_filled(b.expand(1.0), CornerRadius::same(2), ACCENT.gamma_multiply(0.08));
painter.rect_stroke(*b, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
} else {
painter.rect_stroke(*b, CornerRadius::same(2), Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
}
}
let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false };
let Some(hit) = boxes.iter().position(|b| b.contains(p)) else { return false };
painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
let Some(hit) = boxes.iter().rposition(|b| b.contains(p)) else { return false };
if active != Some(hit) {
painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
}
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
if resp.clicked() {
let l = &lines[hit];
// Screen pixels per point, from the box's width.
let scale = (boxes[hit].width() - 4.0) / ((l.rect[2] - l.rect[0]).max(1.0) as f32);
// How far the editor box may grow: a single-line paragraph's rewrite grows to the
// page's right edge, a multi-line paragraph rewraps to its own width.
let right = xf.rect.right().min(view.viewport_rect().right()) - 6.0;
let max_width = if l.lines.len() == 1 { (right - boxes[hit].left()).max(boxes[hit].width()) } else { boxes[hit].width() };
view.line_editor = Some(LineEditor {
page,
block: hit,
text: l.text.clone(),
original: l.text.clone(),
rect: boxes[hit],
source_rect: l.rect.map(|v| v as f32),
multiline: l.lines.len() > 1,
max_width,
size: (l.size as f32 * scale).clamp(8.0, 72.0),
focus: true,
look: look_of(l),
@@ -301,23 +374,46 @@ pub(crate) fn page_input(
}
/// The inline editor; returns the edit once the text is applied.
pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView) -> Option<Edit> {
pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option<Edit> {
// Clicks outside the document (the Format text panel) keep the paragraph open.
let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
let page = view.line_editor.as_ref()?.page;
let xf = view.page_xform(page)?;
let viewport_right = view.viewport_rect().right();
let ed = view.line_editor.as_mut()?;
// Reproject the source box every frame. The page may have been zoomed, scrolled or rotated
// while the format panel was open.
ed.rect = xf.user_rect(info, ed.page, ed.source_rect).expand(2.0);
let right = xf.rect.right().min(viewport_right) - 6.0;
ed.max_width = if ed.multiline { ed.rect.width() } else { (right - ed.rect.left()).max(ed.rect.width()) };
let scale = (ed.rect.width() / (ed.source_rect[2] - ed.source_rect[0]).abs().max(1.0)).max(0.01);
ed.size = (ed.look.size as f32 * scale).clamp(8.0, 72.0);
let font = editor_font(&ed.look, ed.size);
let text_color = color32(ed.look.color);
let mut done = None;
egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show(
ctx,
|ui| {
// The box follows the text as you type: as wide as the longest drafted line needs
// (up to what the rewrite allows), so new content grows the box instead of wrapping
// inside the old one.
let mut width = ed.rect.width().max(120.0);
if ed.max_width > width {
let needed = ui.fonts_mut(|f| {
ed.text.lines().map(|l| f.layout_no_wrap(l.to_owned(), font.clone(), text_color).size().x).fold(0.0_f32, f32::max)
}) + 8.0;
width = width.max(needed).min(ed.max_width);
}
egui::Frame::NONE.fill(Color32::WHITE).stroke(Stroke::new(1.5, ACCENT)).inner_margin(egui::Margin::symmetric(2, 0)).show(ui, |ui| {
let rows = ed.text.lines().count().max(1);
let r = ui.add(
egui::TextEdit::multiline(&mut ed.text)
.id(egui::Id::new("edit-text-line-input"))
.font(egui::FontId::proportional(ed.size))
.text_color(Color32::BLACK)
.font(font.clone())
.text_color(text_color)
.frame(egui::Frame::NONE)
.desired_width(ed.rect.width().max(120.0))
.desired_rows(((ed.rect.height() / (ed.size * 1.2)).round() as usize).max(1)),
.desired_width(width)
.desired_rows(rows),
);
if ed.focus {
r.request_focus();
+5
View File
@@ -288,6 +288,11 @@ fn format_section(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens) {
let edit = printcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() };
if app.apply_edit(edit) {
ed.applied();
if let Some(doc) = app.session.get(app.views[i].id)
&& let Some(block) = doc.text_blocks(ed.page).get(ed.block)
{
ed.refresh_source(block);
}
}
app.views[i].line_editor = Some(ed);
}
+2
View File
@@ -113,7 +113,9 @@ pub fn install_fonts(ctx: &egui::Context) {
add(&mut fonts, "Inter-Medium", include_bytes!("../../../assets/fonts/Inter-Medium.ttf"));
add(&mut fonts, "Inter-SemiBold", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"));
add(&mut fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"));
add(&mut fonts, "ShipporiMincho", include_bytes!("../../../assets/fonts/ShipporiMincho-Regular.ttf"));
fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Inter".to_owned());
fonts.families.entry(FontFamily::Proportional).or_default().push("ShipporiMincho".to_owned());
fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "JetBrainsMono".to_owned());
let fallback: Vec<String> = fonts.families[&FontFamily::Proportional].clone();
for (fam, primary) in [("medium", "Inter-Medium"), ("semibold", "Inter-SemiBold")] {
+127
View File
@@ -630,6 +630,74 @@ fn copying_cutting_and_pasting_pages() {
assert_eq!(page_texts(h.state()).len(), 5);
}
fn source_font_fixture() -> Vec<u8> {
let body = "BT /F1 18 Tf 20 220 Td (Serif) Tj ET BT /F2 18 Tf 20 120 Td (Mono) Tj ET";
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 300] >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-BoldItalic >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 7 0 R /Resources << /Font << /F1 3 0 R /F2 5 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Oblique >>".into(),
"<< /Producer (PrintCraft) >>".into(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn open_source_font_fixture() -> Harness<'static, PrintCraftApp> {
Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PrintCraftApp::new();
app.open_bytes("fonts.pdf", None, source_font_fixture()).expect("font fixture opens");
app
})
}
#[test]
fn clicking_existing_text_selects_its_source_font_style() {
let mut h = open_source_font_fixture();
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let page = h.state().views[0].page_screen_rect(0).expect("on screen");
let click = |x: f32, y: f32| egui::pos2(page.left() + x / 300.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height());
let serif = click(25.0, 228.0);
h.hover_at(serif);
h.run_steps(1);
h.drag_at(serif);
h.run_steps(1);
h.drop_at(serif);
h.run_steps(3);
let ed = h.state().views[0].line_editor.clone().expect("serif editor opens");
assert_eq!(ed.look.family, printcraft_engine::FontFamily::Times);
assert!(ed.look.bold && ed.look.italic, "source style: {:?}", ed.look);
h.key_press(egui::Key::Escape);
h.run_steps(2);
let mono = click(25.0, 128.0);
h.hover_at(mono);
h.run_steps(1);
h.drag_at(mono);
h.run_steps(1);
h.drop_at(mono);
h.run_steps(3);
let ed = h.state().views[0].line_editor.clone().expect("mono editor opens");
assert_eq!(ed.look.family, printcraft_engine::FontFamily::Courier);
assert!(!ed.look.bold && ed.look.italic, "source style: {:?}", ed.look);
}
#[test]
fn editing_existing_text_in_place() {
let mut h = harness(1, |_| {});
@@ -646,6 +714,8 @@ fn editing_existing_text_in_place() {
h.run_steps(3);
let ed = h.state().views[0].line_editor.clone().expect("the line opens for editing");
assert_eq!((ed.page, ed.block, ed.text.as_str()), (0, 0, "Page 1"));
// A single-line paragraph's box may grow to the page's edge as the text does.
assert!(ed.growth().is_some(), "the editor box may grow");
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Chapter One".into();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter);
@@ -657,6 +727,63 @@ fn editing_existing_text_in_place() {
assert_eq!(texts_of(s, 0), ["Chapter One"], "the page shows it");
}
/// One page whose only line is drawn twice at the same spot (fake bold, as many generated
/// documents do).
fn double_drawn() -> Vec<u8> {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(),
format!(
"<< /Length {} >>\nstream\nBT /F1 24 Tf 20 150 Td (Page 1) Tj ET BT /F1 24 Tf 20 150 Td (Page 1) Tj ET\nendstream",
"BT /F1 24 Tf 20 150 Td (Page 1) Tj ET BT /F1 24 Tf 20 150 Td (Page 1) Tj ET".len()
),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn editing_a_double_drawn_line_replaces_every_copy() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PrintCraftApp::new();
app.open_bytes("bold.pdf", None, double_drawn()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(3);
let ed = h.state().views[0].line_editor.clone().expect("the line opens for editing");
assert_eq!(ed.text, "Page 1");
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Replaced".into();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter);
h.run_steps(4);
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().text_lines(0).len(), 1, "one line");
assert_eq!(texts_of(s, 0), ["Replaced"], "no copy of the old text shows under it");
}
#[test]
fn editing_existing_images_on_the_page() {
// A page made from a 40 × 20 image (at 72 dpi: a 40 × 20 pt page filled by it).
+4 -4
View File
@@ -3578,8 +3578,8 @@ milestone = "M5"
status = "shipped"
commands = ["comment.callout"]
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
notes = "drag from the target to the box; resizing re-attaches the leader"
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::editing_a_callout_refits_the_box_and_keeps_the_leader", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
notes = "drag from the target to the box; resizing re-attaches the leader, and so does editing the text"
[[feature]]
id = "comment.pencil"
@@ -3866,8 +3866,8 @@ milestone = "M5"
status = "shipped"
commands = ["comment.list"]
tools = ["comment_edit"]
evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/automation/tests/automation.rs::comments_through_tools"]
notes = "double-click a comment or card, or Edit in the menus"
evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/automation.rs::comments_through_tools"]
notes = "double-click a comment or card, or Edit in the menus; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines)"
[[feature]]
id = "comment.copy-comment-text"