diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 86efcc4fa..482194ec9 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -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) |
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 3c540f1ae..dadd0387d 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -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)"
diff --git a/assets/fonts/OFL-ShipporiMincho.txt b/assets/fonts/OFL-ShipporiMincho.txt
new file mode 100644
index 000000000..a3f24cb5d
--- /dev/null
+++ b/assets/fonts/OFL-ShipporiMincho.txt
@@ -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.
diff --git a/assets/fonts/ShipporiMincho-Regular.ttf b/assets/fonts/ShipporiMincho-Regular.ttf
new file mode 100644
index 000000000..cfc25ceb7
Binary files /dev/null and b/assets/fonts/ShipporiMincho-Regular.ttf differ
diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs
index 6a7d88124..0c5350a36 100644
--- a/crates/annot/src/lib.rs
+++ b/crates/annot/src/lib.rs
@@ -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> { 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::>()).filter(|l| l.len() == 6);
let (outer, cl) = match callout {
diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs
index 8e3f25c33..c1b7929f5 100644
--- a/crates/annot/src/tests.rs
+++ b/crates/annot/src/tests.rs
@@ -279,6 +279,80 @@ fn text_box_text_and_colour_changes_are_drawn() {
assert!(rc.starts_with("Caf\u{e9} (draft) \u{2014} 100%
") && 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 = 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 { 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();
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index 936eeddcb..035eaf16a 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -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 = 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 { .. }));
diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs
index 0cedac78e..709cd9ce1 100644
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -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 = 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::>(), ["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::>(), ["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::>(), ["Hello there", "world"]);
+}
diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs
index 2ae195b13..fad0841db 100644
--- a/crates/edit/src/text.rs
+++ b/crates/edit/src/text.rs
@@ -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,
}
@@ -159,6 +169,31 @@ fn page_dict(doc: &Document, page: usize) -> Result [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,
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 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, 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, 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, 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, EditErro
/// Text → the bytes that show it in the chosen font.
type Encoder = Box Option>>;
-/// 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, 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 {
+ 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> \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> {
+ 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 {
@@ -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 = 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 {
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> {
+ 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, EditError> {
let lines = text_lines(doc, page)?;
@@ -512,6 +722,10 @@ fn group_blocks(lines: &[TextLine]) -> Vec {
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 {
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::()
})
.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::()
+ + 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 = 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> =
+ members.iter().flat_map(|l| [(l.stream, l.ops.iter().copied().collect::>())]).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);
}
}
diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs
index 0d72c01c2..ec836744d 100644
--- a/crates/fonts/src/lib.rs
+++ b/crates/fonts/src/lib.rs
@@ -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 {
diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs
index a242f97da..91c326c79 100644
--- a/crates/fonts/src/pdf.rs
+++ b/crates/fonts/src/pdf.rs
@@ -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, Vec)>, 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.
diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs
index 36737598c..2ee546ad9 100644
--- a/crates/fonts/src/script.rs
+++ b/crates/fonts/src/script.rs
@@ -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>,
@@ -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>,
+ pub width: f64,
+ pub bbox: [f64; 4],
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum GlyphError {
+ Missing,
+ TooComplex,
+}
+
struct Flatten {
contours: Vec>,
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 {
+ 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));
+ }
}
diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs
index db757fb8f..e82783b20 100644
--- a/crates/forms/src/af.rs
+++ b/crates/forms/src/af.rs
@@ -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 {
+ 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());
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index f681cb0d2..2ab31ec71 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -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) {
diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs
index a0f2d92e3..cc9498be7 100644
--- a/crates/ui-egui/src/edit_text_ui.rs
+++ b/crates/ui-egui/src/edit_text_ui.rs
@@ -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 {
+ (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 = 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 {
+pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option {
// 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();
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index a92baf0fc..ba6fab45c 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -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);
}
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
index 0b357dbc8..a0d2abba4 100644
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -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 = fonts.families[&FontFamily::Proportional].clone();
for (fam, primary) in [("medium", "Inter-Medium"), ("semibold", "Inter-SemiBold")] {
diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs
index f87e5a77f..67084f877 100644
--- a/crates/ui-egui/tests/editing.rs
+++ b/crates/ui-egui/tests/editing.rs
@@ -630,6 +630,74 @@ fn copying_cutting_and_pasting_pages() {
assert_eq!(page_texts(h.state()).len(), 5);
}
+fn source_font_fixture() -> Vec {
+ 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 = 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 {
+ let objs: Vec = 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).
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 5f38026d8..a40449f90 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -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"