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"