diff --git a/ROADMAP.md b/ROADMAP.md index 34c4c1893a..6218cda707 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,6 +9,7 @@ > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged) > **Last reviewed:** 2026-10-20 - **Last updated:** 2026-10-20 - **Change:** minor (M6, #3502: form detection uses the existing content-stream boundary policy; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged) +> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M2, #3601: missing spacing glyphs in non-embedded Japanese CID substitutes draw as blanks while PDF widths and Unicode stay unchanged; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) @@ -190,6 +191,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged. - **2026-10-20 (M2, #3574):** Forms without Resources use page Properties for ActualText and layer visibility. Synthetic tests cover boundaries, nested/shared forms, and save/reopen. Estimates unchanged. - **2026-10-20 (M2, PR #3348 review):** Tracking samples retain short repeated punctuation (up to four glyphs), keeping tracked ellipses intact and searchable while longer TOC leaders still preserve title word spaces. Synthetic regressions cover isolated and surrounded ellipses. Estimates unchanged. +- **2026-10-20 (M2, #3601):** A missing spacing glyph in an OpenType substitute uses its ordinary-space glyph. Synthetic Japanese CID renderer tests check blank spaces, `/W` and `/DW` placement, and unchanged Unicode extraction. Font selection and embedded fonts are unchanged. Estimates unchanged. - **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged. - **2026-10-19 (M7, #3591):** A background added to a page that already has one is painted over the old one instead of going under it and staying hidden. Behind the content, backgrounds come first, in the order added, and then behind-content watermarks, so a watermark added after a background no longer disappears. Synthetic regressions in `crates/edit` check the order. Estimates unchanged. diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5a2b4e05ea..d28fbcfb03 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1303,7 +1303,7 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])), - t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.") + t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 9b84ddb077..aa61e3ac5d 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2608,6 +2608,59 @@ fn editing_existing_text_through_tools() { assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}"); } +/// #2593: a paragraph whose own font cannot show a character is written from PdfCraft's fallback +/// faces. The Cyrillic the craft-fonts faces lack (Uzbek қ ғ ҳ, Kazakh ә ө ү ұ һ) comes from the +/// bundled Inter face, so the edit is not refused even without `CRAFT_FONTS_DIR`, and the words +/// survive a save and a reopen. (`crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face` +/// pins which face drew them.) +#[test] +fn editing_uzbek_and_kazakh_cyrillic_through_tools() { + let dir = workdir("cyrillic-fallback"); + let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET"; + std::fs::write(dir.join("cy.pdf"), win_ansi_paragraph_pdf(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "cy.pdf" }))["doc"].as_u64().unwrap(); + for word in ["қизил", "әже"] { + let text = format!("The quick brown fox jumps over the lazy dog {word}\nand keeps running through the long field."); + let edited = ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": text })); + let paragraph = edited["paragraph"]["text"].as_str().unwrap(); + assert!(paragraph.starts_with("The quick brown fox") && paragraph.ends_with("long field."), "{word}: {edited}"); + assert!(paragraph.contains(word), "{word}: {edited}"); + assert!(page_text(&mut a, doc)[0].contains(word), "{word}: the edited page reads it back"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Edit text"); + } + // Keep the Kazakh edit, save it, and read it back from the file on disk. + let text = "The quick brown fox jumps over the lazy dog әже\nand keeps running through the long field."; + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": text })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "cy-out.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "cy-out.pdf" }))["doc"].as_u64().unwrap(); + assert!(page_text(&mut a, saved)[0].contains("әже"), "the saved file reads it back"); +} + +/// One Letter page whose Helvetica is not embedded (WinAnsi), with `body` as its content stream. +fn win_ansi_paragraph_pdf(body: &str) -> Vec { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + bytes +} + /// One page whose content is one stream in three pieces (#155): a `TJ` array ends the middle /// piece and its operator starts the last one. fn split_streams_pdf() -> Vec { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 5c5c92e228..f934f56826 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1354,6 +1354,44 @@ fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() { } } +/// #2593: the craft-fonts faces are Japanese, and part of the Cyrillic (Uzbek қ ғ ҳ, Kazakh ә ө ү +/// ұ һ) is in none of them. The bundled Inter face has it, so the replacement is written instead of +/// being refused, and a build without craft-fonts can do it too. +#[test] +fn uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face() { + for replacement in ["The quick brown fox jumps over the lazy dog қизил", "әже", "ўзбекча ғўза"] { + for paragraph in [false, true] { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let edit = + if paragraph { text::replace_block(&mut doc, 0, 0, replacement) } else { text::replace_line(&mut doc, 0, 0, replacement) }.unwrap(); + assert_eq!(edit.substituted.as_deref(), Some("Inter Type3"), "{replacement}: {edit:?}"); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCLat"), "{replacement}: {content}"); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert_eq!(lines[0].text, replacement); + assert_eq!(lines[0].base_font, "Inter-Regular", "the generated font reports the face it drew"); + } + } +} + +/// The Japanese faces keep first refusal for the Cyrillic they do carry, so Russian text keeps the +/// face it was drawn with before the bundled Latin face was added. +#[test] +fn cyrillic_the_japanese_faces_carry_keeps_them() { + if without_craft_fonts("cyrillic_the_japanese_faces_carry_keeps_them") { + return; + } + let face = pdfcraft_fonts::document_japanese_font_for_style(false, false).unwrap(); + if pdfcraft_fonts::japanese_glyph_from(face, 'ф').is_err() { + eprintln!("skipping: the Japanese fallback face has no Cyrillic"); + return; + } + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let edit = text::replace_line(&mut doc, 0, 0, "Привет, world").unwrap(); + let expected = format!("{} Type3", face.family); + assert_eq!(edit.substituted.as_deref(), Some(expected.as_str())); +} + #[test] fn japanese_paragraph_uses_unicode_type3_fallback() { if without_craft_fonts("japanese_paragraph_uses_unicode_type3_fallback") { @@ -1378,6 +1416,87 @@ fn covered_japanese_chars(n: usize) -> String { .collect() } +#[test] +fn fallback_fonts_nothing_shows_any_more_leave_with_a_rewrite() { + // `PCJp1` is selected by the page, `PCJp2` only by a form that uses the page's resources, `PCJp` + // by nothing; `PCJpOther` is a Type 3 font of the file's own that only shares the prefix. + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Some text) Tj ET BT /PCJp1 12 Tf ET q /Fm0 Do Q"); + let generated = |doc: &mut Document, name: &str| { + doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type3")), + (b"Name".to_vec(), Object::name(name)), + ]))) + }; + let (unused, page_shown, form_shown, own) = + (generated(&mut doc, "PCJapanese"), generated(&mut doc, "PCJapanese"), generated(&mut doc, "PCJapanese"), generated(&mut doc, "Other")); + let form = doc.add(Object::Stream(Stream::from_raw( + Dict::from_iter([(b"Subtype".to_vec(), Object::name("Form")), (b"BBox".to_vec(), Object::Array(vec![Object::Int(0); 4]))]), + b"BT /PCJp2 12 Tf ET".to_vec(), + ))); + let p = pdfcraft_model::pages(&doc)[0].obj; + let mut fonts = Dict::from_iter([(b"F1".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)))]); + for (name, font) in [("PCJp", unused), ("PCJp1", page_shown), ("PCJp2", form_shown), ("PCJpOther", own)] { + fonts.set(name.as_bytes().to_vec(), Object::Ref(font)); + } + let resources = Dict::from_iter([ + (b"Font".to_vec(), Object::Dict(fonts)), + (b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm0".to_vec(), Object::Ref(form))]))), + ]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources.clone()))).unwrap(); + let names = |doc: &Document| { + let page = pdfcraft_model::pages(doc).remove(0); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let mut names: Vec = + doc.resolve(res.get(b"Font").unwrap()).as_dict().unwrap().iter().map(|(n, _)| String::from_utf8_lossy(n).into_owned()).collect(); + names.sort(); + names + }; + // A rewrite that doesn't reach the fallback fonts still drops the one nothing shows. + text::rewrite_block(&mut doc, 0, 0, Some("Other text"), &text::BlockStyle::default()).unwrap(); + assert_eq!(names(&reopen(&doc)), ["F1", "PCJp1", "PCJp2", "PCJpOther"]); + + // A form using the page's resources whose content can't be read might show any of them: they + // all stay. + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Some text) Tj ET q /Fm0 Do Q"); + let unused = generated(&mut doc, "PCJapanese"); + let broken = doc.add(Object::Stream(Stream::from_raw( + Dict::from_iter([ + (b"Subtype".to_vec(), Object::name("Form")), + (b"BBox".to_vec(), Object::Array(vec![Object::Int(0); 4])), + (b"Filter".to_vec(), Object::name("FlateDecode")), + ]), + b"not deflate data".to_vec(), + ))); + let p = pdfcraft_model::pages(&doc)[0].obj; + let fonts = Dict::from_iter([(b"F1".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(5, 0))), (b"PCJp".to_vec(), Object::Ref(unused))]); + let resources = Dict::from_iter([ + (b"Font".to_vec(), Object::Dict(fonts)), + (b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm0".to_vec(), Object::Ref(broken))]))), + ]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + text::rewrite_block(&mut doc, 0, 0, Some("Other text"), &text::BlockStyle::default()).unwrap(); + assert_eq!(names(&doc), ["F1", "PCJp"]); +} + +#[test] +fn rewriting_japanese_twice_keeps_only_the_fallback_fonts_it_shows() { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj ET"); + if text::rewrite_block(&mut doc, 0, 0, Some("日本語の文章"), &text::BlockStyle::default()).is_err() { + assert!(pdfcraft_fonts::document_japanese_font().is_none()); + eprintln!("built without a craft-fonts Japanese face: the Type 3 path is checked with CRAFT_FONTS_DIR"); + return; + } + let first: Vec<_> = fallback_fonts(&doc).into_iter().map(|(_, f)| f).collect(); + text::rewrite_block(&mut doc, 0, 0, Some("別の文章です"), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let after = fallback_fonts(&doc); + assert!(!after.is_empty()); + assert!(after.iter().all(|(_, f)| !first.contains(f)), "the first rewrite's fonts are gone"); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(after.iter().all(|(name, _)| content.contains(&format!("/{} ", String::from_utf8_lossy(name)))), "{content}"); +} + /// The page's Type 3 fallback fonts (`PCJp`, `PCJp1`, ...) by resource name. fn fallback_fonts(doc: &Document) -> Vec<(Vec, Dict)> { let p = pdfcraft_model::pages(doc).swap_remove(0); @@ -5127,16 +5246,18 @@ fn a_shaped_run_is_one_text_object_with_each_glyph_where_shaping_put_it() { #[test] fn a_subset_program_is_not_trusted_for_new_characters_and_the_error_names_the_character() { + let mut doc = embedded_program_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "ABCDEF+Inter-Regular"); + // The subset's own program is not trusted for a character: the bundled Inter face draws it + // through a generated font instead (the subset may map a code whose outline was dropped). + let edit = text::rewrite_block(&mut doc, 0, 0, Some("Stable tőxt"), &text::BlockStyle::default()).unwrap(); + assert_eq!(edit.substituted.as_deref(), Some("Inter Type3")); + assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, "Stable tőxt"); + // A character no fallback face has is refused, named, and left as it was. let mut doc = embedded_program_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "ABCDEF+Inter-Regular"); let before = page_content_bytes(&doc, 0); - let result = text::rewrite_block(&mut doc, 0, 0, Some("Stable tőxt"), &text::BlockStyle::default()); - if pdfcraft_fonts::document_japanese_font().is_some() { - eprintln!("built with craft-fonts: the fallback font draws the new character"); - assert!(result.is_ok(), "{result:?}"); - return; - } + let result = text::rewrite_block(&mut doc, 0, 0, Some("Stable กtext"), &text::BlockStyle::default()); let Err(EditError::Invalid(msg)) = result else { panic!("expected a clear error, got {result:?}") }; - assert!(msg.contains("ő (U+0151)") && msg.contains("Load font") && !msg.contains("Japanese"), "{msg}"); + assert!(msg.contains("ก (U+0E01)") && msg.contains("Load font") && !msg.contains("Japanese"), "{msg}"); assert_eq!(page_content_bytes(&doc, 0), before); } diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index c6e6982aa8..6e8d45da14 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1645,12 +1645,111 @@ fn needs_type3(text: &str) -> bool { const JAPANESE_FONT_NAME: &str = "PCJapanese"; /// The `/Name` of the Type 3 fonts generated for Hebrew text. const HEBREW_FONT_NAME: &str = "PCHebrew"; +/// The `/Name` of the Type 3 fonts generated from the bundled Latin (Inter) face. +const LATIN_FONT_NAME: &str = "PCLatin"; -/// Whether `font` is one of those generated fonts (Japanese or Hebrew): a replacement longer than -/// one of them is split across several, which read back as one line (see [`same_font`]). +/// Whether `font` is one of those generated fonts (Latin, Japanese or Hebrew): a replacement longer +/// than one of them is split across several, which read back as one line (see [`same_font`]). fn is_type3_fallback(font: &Dict) -> bool { font.name(b"Subtype") == Some(b"Type3") - && font.name(b"Name").is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes()) + && font + .name(b"Name") + .is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == LATIN_FONT_NAME.as_bytes()) +} + +/// The generated font's `/Name` and its resource prefix: the bundled Latin face, Hebrew, or the +/// Japanese ones. +fn fallback_names(face: &CraftFont, hebrew: bool) -> (&'static str, &'static str) { + if hebrew { + (HEBREW_FONT_NAME, "PCHe") + } else if face.family == "Inter" { + (LATIN_FONT_NAME, "PCLat") + } else { + (JAPANESE_FONT_NAME, "PCJp") + } +} + +/// Drops the generated fallback fonts ([`is_type3_fallback`]) that nothing on `page` shows any more. +/// Each rewrite generates its own, with the outlines of every character it draws, so without this +/// an earlier rewrite's fonts stay in the page's resources and the file. Only a font dictionary the +/// page owns is changed (a rewrite gives the page its own), and nothing is dropped unless every +/// content stream that may name the page's fonts can be read: the page's own, and those of form +/// XObjects and Type 3 glyphs that have no resources of their own and so use the page's. +fn drop_unused_fallback_fonts(doc: &mut Document, page: usize) { + let Ok(p) = page_dict(doc, page) else { return }; + let Some(Object::Dict(mut resources)) = p.dict.get(b"Resources").cloned() else { return }; + let Some(mut fonts) = resources.get(b"Font").and_then(|f| doc.dict(f)) else { return }; + let generated: Vec> = + fonts.iter().filter(|(_, f)| doc.dict(f).is_some_and(|d| is_type3_fallback(&d))).map(|(name, _)| name.clone()).collect(); + if generated.is_empty() { + return; + } + let Some(shown) = fonts_shown(doc, &p.dict, &resources) else { return }; + let unused: Vec<&Vec> = generated.iter().filter(|name| !shown.contains(*name)).collect(); + if unused.is_empty() { + return; + } + for name in unused { + fonts.remove(name); + } + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + // Nothing is lost if this fails: the fonts just stay. + let _ = doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))); +} + +/// The font resource names `Tf` selects in the page's content and in the content that uses the +/// page's resources (see [`drop_unused_fallback_fonts`]), or `None` if any of it can't be read. +fn fonts_shown(doc: &Document, page: &Dict, resources: &Dict) -> Option>> { + let mut streams: Vec = match page.get(b"Contents") { + None => Vec::new(), + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }, + }; + // Form XObjects without their own resources use the page's, however deeply they are nested + // (ISO 32000-1 §7.8.3), so every form the page can reach is visited. + let mut pending: Vec = vec![resources.clone()]; + let mut seen = std::collections::HashSet::new(); + while let Some(res) = pending.pop() { + for (_, x) in res.get(b"XObject").and_then(|x| doc.dict(x)).unwrap_or_default().iter() { + let Some(r) = x.as_ref() else { continue }; + if !seen.insert(r) { + continue; + } + if seen.len() > 4096 { + return None; + } + let Object::Stream(s) = &*doc.get(r) else { continue }; + if s.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + match s.dict.get(b"Resources").and_then(|r| doc.dict(r)) { + Some(own) => pending.push(own), + None => streams.push(x.clone()), + } + } + } + // And so do Type 3 glyph procedures without their own (the generated fonts' are outlines only). + for (_, f) in resources.get(b"Font").and_then(|f| doc.dict(f)).unwrap_or_default().iter() { + if let Some(font) = doc.dict(f).filter(|d| d.name(b"Subtype") == Some(b"Type3") && !d.contains(b"Resources") && !is_type3_fallback(d)) { + streams.extend(font.get(b"CharProcs").and_then(|c| doc.dict(c)).unwrap_or_default().iter().map(|(_, proc)| proc.clone())); + } + } + let mut shown = std::collections::HashSet::new(); + for stream in streams { + let Object::Stream(s) = &*doc.resolve(&stream) else { return None }; + // Strict: a stream only partly read might name a font in the part that wasn't. + let data = s.decoded_strict().ok()?; + for op in parse(&data).ops { + if op.is("Tf") + && let Some(name) = op.operands.first().and_then(|n| n.as_name()) + { + shown.insert(name.to_vec()); + } + } + } + Some(shown) } /// Whether a run shown in `font` (a resource name), with `/BaseFont` `base_font` and the Japanese @@ -1699,7 +1798,8 @@ const MAX_TYPE3_GLYPHS: usize = 240; struct Type3Fallback { /// The family its glyphs come from ("Shippori Mincho"). family: &'static str, - /// The fonts' resource names in order; the first is `PCJp` (`PCHe` for Hebrew text). + /// The fonts' resource names in order; the first is `PCJp` (`PCLat` for the bundled Latin + /// face, `PCHe` for Hebrew text). names: Vec, /// Every unique character with its advance (em), in order of first appearance. widths: Vec<(char, f64)>, @@ -1773,11 +1873,19 @@ fn unicode_hex(ch: char) -> String { encoded.iter().map(|u| format!("{u:04X}")).collect() } -/// No font this build has can draw `text`. Only Japanese text is about the Japanese face: other +/// The same outline for a face that may simply lack the glyph: `None` then, so the caller can try +/// the next face (and report the character once none of them has it). +fn type3_glyph(face: &CraftFont, ch: char) -> Result, f64)>, EditError> { + match japanese_glyph_from(face, ch) { + Err(GlyphError::Missing) => Ok(None), + _ => type3_path(face, ch).map(Some), + } +} + +/// No face this build has can draw `text`. Only Japanese text is about the Japanese face: other /// characters are missing from the paragraph's font, and choosing a font that has them works too. fn no_fallback_font(text: &str) -> EditError { - let japanese = |c: char| matches!(u32::from(c), 0x3000..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF); - if text.chars().any(japanese) { + if text.chars().any(needs_japanese) { return no_japanese_font(); } let Some(ch) = text.chars().find(|c| !c.is_whitespace() && !is_win_ansi_char(*c)) else { return no_japanese_font() }; @@ -1789,6 +1897,28 @@ fn no_fallback_font(text: &str) -> EditError { )) } +/// The character the craft-fonts Japanese faces are for: kana, CJK ideographs and their compatibility +/// and full-width forms. +fn needs_japanese(ch: char) -> bool { + matches!(u32::from(ch), 0x3000..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF) +} + +/// The chosen face has no outline for `ch`, and neither has any other: say which character it is and +/// what the writer can do about it. A CJK character without a Japanese face in the build is about +/// that build input, which is the one answer that changes it. +fn no_glyph_for(face: &CraftFont, ch: char) -> EditError { + if needs_japanese(ch) && pdfcraft_fonts::document_japanese_font().is_none() { + return no_japanese_font(); + } + EditError::Invalid(format!( + "the {} fallback font has no glyph for {ch} (U+{:04X}), and no fallback face covers the text; \ + choose a font that has it (Load font… or Installed fonts…), or use an official release, which includes \ + PdfCraft's fallback fonts (to build them in, set CRAFT_FONTS_DIR to a craft-fonts checkout)", + face.family, + u32::from(ch) + )) +} + /// This build has no Japanese face to draw replacement text with. fn no_japanese_font() -> EditError { EditError::Invalid( @@ -1813,12 +1943,15 @@ fn no_hebrew_font() -> EditError { } /// The faces a Type 3 replacement may draw `text` with, best first. Text with Hebrew takes the -/// Hebrew face first (its Latin and digits too, so a line keeps one look), then the Japanese -/// faces for anything it lacks; other text takes the Japanese faces, as before. +/// Hebrew face first (its Latin and digits too, so a line keeps one look), then the Japanese faces +/// and the bundled Latin one for anything it lacks; other text takes the Japanese faces, and the +/// bundled Latin face behind them, which covers the Latin, Greek and Cyrillic letters they lack +/// (#2593: the Japanese faces have no қ, ғ, ҳ, ә, ө, ү, ұ or һ). fn type3_faces(text: &str, family: crate::added::Family, bold: bool) -> Result<(Vec<&'static CraftFont>, bool), EditError> { let japanese = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold); + let latin = pdfcraft_fonts::document_latin_fonts(bold); if !text.chars().any(crate::bidi::is_hebrew) { - return Ok((japanese, false)); + return Ok((japanese.into_iter().chain(latin).collect(), false)); } // Points are drawn on their letter with the font's positioning, which a one-glyph-per- // character Type 3 font can't do; refuse rather than print them beside the letter. @@ -1826,7 +1959,7 @@ fn type3_faces(text: &str, family: crate::added::Family, bold: bool) -> Result<( return Err(EditError::Invalid("Hebrew points (niqqud) can't be written into a PDF yet; type the text without them".into())); } let hebrew = pdfcraft_fonts::document_hebrew_font(bold).ok_or_else(no_hebrew_font)?; - Ok((std::iter::once(hebrew).chain(japanese).collect(), true)) + Ok((std::iter::once(hebrew).chain(japanese).chain(latin).collect(), true)) } fn type3_font( @@ -1860,8 +1993,16 @@ fn type3_font( let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); let mut glyphs = Vec::with_capacity(chars.len()); for (i, ch) in chars.into_iter().enumerate() { - // Hebrew text: the first face with the glyph, else the Hebrew face's error. - let (path, width) = others.iter().fold(type3_path(face, ch), |found, f| found.or_else(|e| type3_path(f, ch).map_err(|_| e)))?; + // The chosen face first, then the faces after it (Hebrew text takes a character the Hebrew + // face lacks from the next face that has it); a character no face draws is reported once. + let mut drawn = type3_glyph(face, ch)?; + for f in others.iter().copied() { + if drawn.is_some() { + break; + } + drawn = type3_glyph(f, ch)?; + } + let Some((path, width)) = drawn else { return Err(no_glyph_for(face, ch)) }; let code = u8::try_from((i % per_font).saturating_add(1)) .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; glyphs.push(Type3Glyph { code, ch, path, width }); @@ -1885,7 +2026,7 @@ fn type3_font( } /// Adds one generated font whose codes 1 to `glyphs.len()` show `glyphs`, and returns its resource -/// name (`PCJp`, then `PCJp1`, ...; `PCHe`, ... for Hebrew text). Nothing here can fail: the +/// name (`PCLat`, `PCJp`, then `PCJp1`, ...; `PCHe`, ... for Hebrew text). Nothing here can fail: the /// outlines were read already. fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, hebrew: bool, glyphs: Vec) -> String { let last_char = glyphs.len(); @@ -1916,7 +2057,8 @@ fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, he 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(if hebrew { HEBREW_FONT_NAME } else { JAPANESE_FONT_NAME })); + let (font_name, prefix) = fallback_names(face, hebrew); + font.set(b"Name".to_vec(), Object::name(font_name)); let mut descriptor = Dict::new(); descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); descriptor.set(b"FontName".to_vec(), Object::name(&format!("{}-{}", face.family, face.style).replace(' ', ""))); @@ -1936,7 +2078,6 @@ fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, he 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 prefix = if hebrew { "PCHe" } else { "PCJp" }; let mut name = String::from(prefix); let mut suffix = 0usize; while fonts_res.contains(name.as_bytes()) { @@ -2056,7 +2197,9 @@ fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacin /// 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 { - replace_line_with_limit(doc, page, line, text, MAX_TYPE3_GLYPHS) + let edit = replace_line_with_limit(doc, page, line, text, MAX_TYPE3_GLYPHS)?; + drop_unused_fallback_fonts(doc, page); + Ok(edit) } /// Refuses a rewrite that would remove text drawn as a clipping path (`4 Tr` to `7 Tr`). Such @@ -2443,7 +2586,9 @@ pub struct RangeStyle { /// Rewrite paragraph `block` with new text (or its own) and formatting, rewrapped to its width. pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option<&str>, style: &BlockStyle) -> Result { - rewrite_block_with_limit(doc, page, block, text, style, MAX_TYPE3_GLYPHS) + let edit = rewrite_block_with_limit(doc, page, block, text, style, MAX_TYPE3_GLYPHS)?; + drop_unused_fallback_fonts(doc, page); + Ok(edit) } /// [`rewrite_block`] with the most unique characters one generated font may hold (tests lower it). diff --git a/crates/fonts/src/craft.rs b/crates/fonts/src/craft.rs index 099179cf2e..50e05adf43 100644 --- a/crates/fonts/src/craft.rs +++ b/crates/fonts/src/craft.rs @@ -138,6 +138,24 @@ pub fn document_japanese_fonts_for_style(serif: bool, bold: bool) -> Vec<&'stati document_faces(CRAFT_FONTS, serif, bold) } +/// The scripts the bundled Inter faces are for. +static LATIN_SCRIPTS: &[&str] = &["Latn", "Cyrl", "Grek"]; + +/// The bundled Latin document faces (`assets/fonts`, OFL): every build has them, unlike +/// craft-fonts. The Japanese faces carry only part of the Cyrillic (`привет` yes, Uzbek `қизил` +/// and Kazakh `әже` no), so letters like қ, ғ, ҳ, ә, ө, ү, ұ and һ have no face at all without +/// these. +static INTER_REGULAR_FACE: CraftFont = + CraftFont { family: "Inter", style: "Regular", scripts: LATIN_SCRIPTS, bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf") }; +static INTER_SEMIBOLD_FACE: CraftFont = + CraftFont { family: "Inter", style: "SemiBold", scripts: LATIN_SCRIPTS, bytes: include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf") }; + +/// The face for Latin, Greek and Cyrillic text written into PDFs: the bundled Inter Regular, or +/// SemiBold for bold text. Present in every build, crafted ones included. +pub fn document_latin_fonts(bold: bool) -> Vec<&'static CraftFont> { + vec![if bold { &INTER_SEMIBOLD_FACE } else { &INTER_REGULAR_FACE }] +} + fn document_face(faces: &[CraftFont], serif: bool, bold: bool) -> Option<&CraftFont> { document_faces(faces, serif, bold).into_iter().next() } @@ -296,6 +314,20 @@ mod tests { assert_eq!(document_hebrew_font(false).is_some(), CRAFT_FONTS.iter().any(|f| f.covers("Hebr"))); } + /// The bundled Inter faces carry the Latin, Greek and Cyrillic letters the craft-fonts faces + /// lack: Uzbek қ ғ ҳ, Kazakh ә ө ү ұ һ, Ukrainian ї є ґ, Greek. This one runs with or without + /// CRAFT_FONTS_DIR (#2593). + #[test] + fn latin_faces_carry_the_letters_the_japanese_faces_lack() { + for face in document_latin_fonts(false).into_iter().chain(document_latin_fonts(true)) { + for ch in ['қ', 'ғ', 'ҳ', 'ә', 'ө', 'ү', 'ұ', 'һ', 'ї', 'є', 'ґ', 'ё', 'α', 'β', 'γ', '≥', '≈'] { + assert!(crate::japanese_glyph_from(face, ch).is_ok(), "{} has no {ch}", face.name()); + } + } + assert_eq!(document_latin_fonts(false)[0].style, "Regular"); + assert_eq!(document_latin_fonts(true)[0].style, "SemiBold"); + } + #[test] fn craft_fonts_are_optional_and_consistent() { // Holds with or without CRAFT_FONTS_DIR. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index a1f2ff2779..21c4fdd661 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -19,8 +19,8 @@ mod std14; mod std14_metrics; pub use craft::{ CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_hebrew_font, document_japanese_font, document_japanese_font_for_style, - document_japanese_fonts_for_style, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, ui_japanese_fonts, - ui_telugu_fonts, ui_thai_fonts, + document_japanese_fonts_for_style, document_latin_fonts, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, + ui_japanese_fonts, ui_telugu_fonts, ui_thai_fonts, }; pub use script::{ GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline, @@ -69,7 +69,7 @@ fn is_wide(c: char) -> bool { ) } -/// Greedy line breaking within `width` points (paragraphs split on newlines; words longer +/// Greedy line breaking within `width` points (paragraphs split on line breaks, where CRLF counts as one; words longer /// than a line are broken by character). pub fn wrap(text: &str, size: f64, width: f64) -> Vec { wrap_with(text, size, width, helvetica_width) @@ -78,7 +78,8 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec { /// [`wrap`], measuring with `measure` (for example [`cjk_width`]). pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec { let mut lines = Vec::new(); - for para in text.split(['\n', '\r']) { + // A CRLF pair is one break, a lone CR or LF is one break each. + for para in text.replace("\r\n", "\n").split(['\n', '\r']) { let mut line = String::new(); // No word is on the line yet; an empty word (a leading space) still starts it. let mut fresh = true; @@ -277,6 +278,15 @@ mod tests { assert_eq!(broken, wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0)); } + #[test] + fn crlf_is_one_line_break() { + assert_eq!(wrap("a\r\nb", 12.0, 100.0), ["a", "b"]); + assert_eq!(wrap("a\rb", 12.0, 100.0), ["a", "b"]); + assert_eq!(wrap("a\nb", 12.0, 100.0), ["a", "b"]); + assert_eq!(wrap("a\r\n\r\nb", 12.0, 100.0), ["a", "", "b"]); + assert_eq!(wrap("a\n\rb", 12.0, 100.0), ["a", "", "b"]); + } + #[test] fn win_ansi_encodability_matches_the_encoder() { for c in ['a', 'é', 'ÿ', '€', '—', '™', '\t', '?'] { diff --git a/crates/fonts/src/std14.rs b/crates/fonts/src/std14.rs index 0556b112c3..aae6575018 100644 --- a/crates/fonts/src/std14.rs +++ b/crates/fonts/src/std14.rs @@ -81,7 +81,7 @@ impl Std14 { if has("times") { return Some((times(), Match::Alias)); } - if has("serif") || has("roman") { + if (has("serif") && !has("sans")) || has("roman") { return Some((times(), Match::FamilyHint)); } if has("helvetica") || has("arial") { @@ -125,6 +125,12 @@ impl Std14 { mod tests { use super::*; + #[test] + fn sans_serif_names_are_not_mapped_to_times() { + assert!(!matches!(Std14::recognise("SansSerif"), Some((Std14::TimesRoman, _)))); + assert_eq!(Std14::recognise("Arial-SansSerif"), Some((Std14::Helvetica, Match::Alias))); + } + /// Widths from the published Core14 metrics, typed in here by hand so the table is checked /// against the specification rather than against whatever produced it. #[test] diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 62f2ed99c4..1f8d2f19ef 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -4972,6 +4972,71 @@ trailer << /Root 1 0 R >> assert_eq!((last.width, last.height), (50, 100), "the last page is read from its own offset"); } + /// #2946: ScanSoft PDF Create! writes the `post` table version of every font it embeds with + /// its 16-bit halves swapped (`00 00 00 03` for 3.0). Such a font must still be used, drawing + /// exactly as with a correct version; a version that is neither (9.0) is still refused and + /// drawn with a substitute, as before. + #[test] + fn a_font_whose_post_version_has_swapped_halves_is_still_used() { + let font = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"); + // The `post` table's offset, from the table directory. + let tables = usize::from(u16::from_be_bytes([font[4], font[5]])); + let post = (0..tables) + .map(|i| &font[12 + 16 * i..28 + 16 * i]) + .find(|e| &e[..4] == b"post") + .map(|e| u32::from_be_bytes([e[8], e[9], e[10], e[11]]) as usize) + .expect("the test font has a post table"); + let render_with = |version: [u8; 4], encoding: &str| -> Vec { + let mut program = font.to_vec(); + program[post..post + 4].copy_from_slice(&version); + let content = b"BT /F1 28 Tf 10 40 Td (Hamburg 0123) Tj ET"; + let widths = vec!["600"; 95].join(" "); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 80] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(), content.to_vec(), b"\nendstream".to_vec()].concat(), + format!("<< /Type /Font /Subtype /TrueType /BaseFont /JetBrainsMono-Regular /FirstChar 32 /LastChar 126 /Widths [{widths}] /FontDescriptor 6 0 R /Encoding {encoding} >>").into_bytes(), + b"<< /Type /FontDescriptor /FontName /JetBrainsMono-Regular /Flags 32 /FontBBox [-1000 -300 1000 1000] /ItalicAngle 0 /Ascent 1020 /Descent -300 /CapHeight 730 /StemV 80 /FontFile2 7 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", program.len()).into_bytes(), program, b"\nendstream".to_vec()].concat(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 2.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{:?}", page.error); + page.rgba.to_vec() + }; + let render = |version: [u8; 4]| render_with(version, "/WinAnsiEncoding"); + let correct = render([0, 3, 0, 0]); + assert_eq!(render([0, 0, 0, 3]), correct, "3.0 with swapped halves draws with the embedded font"); + assert_eq!(render([0, 0, 0, 2]), render([0, 2, 0, 0]), "2.0 with swapped halves too"); + assert_ne!(render([0, 9, 0, 0]), correct, "an unknown version is still refused"); + // A /Differences name only the font's own post table knows (W's alternate, `W.cv07`) + // is found through a swapped 2.0 table too (review on #2949). + let alternate = |version: [u8; 4]| render_with(version, "<< /BaseEncoding /WinAnsiEncoding /Differences [72 /W.cv07] >>"); + assert_ne!(alternate([0, 2, 0, 0]), render([0, 2, 0, 0]), "the alternate W draws instead of H"); + assert_eq!(alternate([0, 0, 0, 2]), alternate([0, 2, 0, 0]), "and also with swapped halves"); + } + /// A page whose content expands far beyond its compressed stream stops at the expansion budget, /// which is 64 times the compressed size and at least `MAX_DECODED_STREAM`, and the page says so. #[test] @@ -6246,6 +6311,82 @@ trailer << /Root 1 0 R >> } } + /// Comma-below letters in non-embedded standard fonts (vendored hayro-interpret patch to + /// `StandardKind`). The stand-ins have neither these letters nor a `commaaccent` to build + /// them from, so Romanian ș Ș (a form laid out from its XFA template names `/scommaaccent`) + /// and Latvian ķ ļ ņ ŗ Ģ drew nothing, and ț Ț drew a cedilla. Each is now its letter with a + /// comma under it, apart from the letter and inside the descender, whichever name the font + /// gives it: the glyph list calls the Latvian letters `/Kcedilla`…, and `uni` names say only + /// the code point. + #[test] + fn comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter() { + let pairs = [ + ("s", "uni0219"), + ("t", "uni021B"), + ("T", "uni021A"), + ("K", "uni0136"), + ("k", "kcedilla"), + ("G", "uni0122"), + ("G", "Gcedilla"), + ("R", "uni0156"), + ("s", "scommaaccent"), + ("S", "Scommaaccent"), + ("t", "tcommaaccent"), + ("T", "Tcommaaccent"), + ("k", "kcommaaccent"), + ("K", "Kcommaaccent"), + ("l", "lcommaaccent"), + ("L", "Lcommaaccent"), + ("n", "ncommaaccent"), + ("N", "Ncommaaccent"), + ("r", "rcommaaccent"), + ("R", "Rcommaaccent"), + ("G", "Gcommaaccent"), + ]; + let letters: Vec<&str> = pairs.iter().flat_map(|(base, accented)| [*base, *accented]).collect(); + let (size, baseline) = (40u32, 60u32); + for font in ["Helvetica", "Times-Roman", "Courier", "Helvetica-BoldOblique"] { + let mut content = String::new(); + for i in 0..letters.len() { + content.push_str(&format!("BT /F1 {size} Tf {} {} Td <{:02X}> Tj ET\n", 10 + 40 * i, 80 - baseline, 1 + i)); + } + let differences = letters.iter().map(|n| format!("/{n}")).collect::>().join(" "); + let pdf = format!( + "%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {} 80] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content}endstream endobj +5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /{font} + /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [1 {differences}] >> >> endobj +trailer << /Root 1 0 R >> +%%EOF", + 20 + 40 * letters.len(), + content.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{font}: {:?}", p.error); + // The rows (y down) of the i-th letter that have dark pixels. + let ink_rows = |i: u32| -> Vec { + (0..p.height).filter(|y| ((8 + 40 * i)..(48 + 40 * i).min(p.width)).any(|x| p.rgba[((y * p.width + x) * 4) as usize] < 128)).collect() + }; + for (pair, (_, name)) in pairs.iter().enumerate() { + let (base, accented) = (ink_rows(2 * pair as u32), ink_rows(2 * pair as u32 + 1)); + let (Some(&base_bottom), Some(&bottom)) = (base.last(), accented.last()) else { + panic!("{font} {name}: draws nothing ({} rows of its base letter)", base.len()); + }; + assert!(bottom > base_bottom + 2, "{font} {name}: a mark hangs below the letter ({bottom} vs {base_bottom})"); + assert!( + (base_bottom + 1..bottom).any(|y| !accented.contains(&y)), + "{font} {name}: the mark is apart from the letter, a comma and not a cedilla" + ); + assert!(bottom < baseline + size * 3 / 10, "{font} {name}: the comma stays within the descender ({bottom})"); + } + } + } + // Contributor-original classic-xref vector fixtures; no embedded or external assets. fn raw_crop_unit_pdf(unit: f64, rotation: i32, crop: [f64; 4], marker: Option<[f64; 4]>) -> Vec { let bounds = crop.map(|n| n.to_string()).join(" "); @@ -7487,6 +7628,61 @@ trailer << /Root 1 0 R >> } } + // #3601: UniJIS-UCS2-HW-H maps code 0x0020 to CID 231 / U+2002. The PDF's + // explicit 500-unit width must win over the substitute's ordinary-space width. + fn japanese_spacing_pdf(content: &str, widths: &str) -> Vec { + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 180 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /MS-Mincho /Encoding /UniJIS-UCS2-HW-H /DescendantFonts [6 0 R] >> endobj +6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /MS-Mincho /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 4 >> + /FontDescriptor 7 0 R /DW 1000 {widths} >> endobj +7 0 obj << /Type /FontDescriptor /FontName /MS-Mincho /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ) + .into_bytes() + } + + #[test] + fn japanese_substitute_missing_spaces_draw_no_ink() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese face to check"); + return; + } + let out = render_first_page(japanese_spacing_pdf("BT /F1 20 Tf 10 30 Td <002000200020> Tj ET", "/W [231 [500]]")); + assert!(out.rgba.as_chunks::<4>().0.iter().all(|pixel| *pixel == [255; 4]), "spacing glyphs must leave the page white"); + } + + #[test] + fn japanese_substitute_spaces_keep_pdf_widths_and_unicode() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese face to check"); + return; + } + for (widths, second_x) in [("/W [231 [500]]", 50), ("", 70)] { + let pdf = japanese_spacing_pdf("BT /F1 20 Tf 10 30 Td <65E500200020672C> Tj ET", widths); + let out = render_first_page(pdf.clone()); + // 日 advances 20 pt; each space advances 10 pt (/W) or 20 pt (/DW). + // A second independent text matrix pins 本 at the expected original PDF position. + let reference = format!("BT /F1 20 Tf 1 0 0 1 10 30 Tm <65E5> Tj 1 0 0 1 {second_x} 30 Tm <672C> Tj ET"); + let expected = render_first_page(japanese_spacing_pdf(&reference, widths)); + assert!(expected.rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[0] < 128), "the Japanese control must draw ink"); + assert!(out.rgba.as_ref() == expected.rgba.as_ref(), "spaces must be blank without changing {widths:?} advances"); + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(text.error.is_none(), "{:?}", text.error); + let glyphs: String = text.text.unwrap().glyphs.iter().map(|glyph| glyph.text.as_str()).collect(); + assert_eq!(glyphs, "日\u{2002}\u{2002}本", "glyph substitution must not rewrite Unicode"); + } + } + /// Half-width katakana are common in Japanese documents set in a non-embedded Mincho font /// such as HeiseiMin-W3. The Mincho substitute must have those glyphs: one that lacks them /// (Shippori Mincho) draws them as .notdef. diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs index 606fa923dc..effb050fbe 100644 --- a/crates/ui-egui/src/system_fonts.rs +++ b/crates/ui-egui/src/system_fonts.rs @@ -3,10 +3,10 @@ //! The embedded faces (Inter, egui's defaults, craft-fonts) come first in every family; this one //! only draws characters none of them has, such as an Arabic file name in a build without //! craft-fonts, or the Simplified Chinese interface in a build without a `Hans` face (#826). A -//! few more installed faces, [`devanagari_fallback`], [`telugu_fallback`], [`sinhala_fallback`] -//! and [`tamil_fallback`], draw Hindi, Telugu, Sinhala and Tamil when one is installed. It is -//! read at runtime and never embedded or shipped (AGENTS.md §1.4), and `PDFCRAFT_SYSTEM_FONTS=0` -//! turns it off (published screenshots do). +//! few more installed faces, [`devanagari_fallback`], [`telugu_fallback`], [`sinhala_fallback`], +//! [`tamil_fallback`] and [`hebrew_fallback`], draw Hindi, Telugu, Sinhala, Tamil and Hebrew when +//! one is installed. It is read at runtime and never embedded or shipped (AGENTS.md §1.4), and +//! `PDFCRAFT_SYSTEM_FONTS=0` turns it off (published screenshots do). use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; @@ -32,6 +32,8 @@ const TELUGU_PROBE: char = '\u{0C15}'; const SINHALA_PROBE: char = '\u{0D9A}'; /// Tamil letter ka (க): the face must have it to cover Tamil. const TAMIL_PROBE: char = '\u{0B95}'; +/// Hebrew letter alef: the face must have it to cover Hebrew. +const HEBREW_PROBE: char = '\u{05D0}'; /// 欢 (U+6B22): Simplified only, absent from the Japanese craft-fonts faces. const HANS_PROBE: char = '\u{6B22}'; /// 說 (U+8AAA): Traditional only, absent from the Japanese craft-fonts faces. @@ -103,6 +105,13 @@ pub fn tamil_fallback() -> Option> { CACHE.get_or_init(|| script_fallback(&tamil_candidates(), TAMIL_PROBE)).clone() } +/// The installed fallback face for Hebrew, read once. `None` when it is turned off or no +/// candidate is installed; the interface then draws what the embedded faces can. +pub fn hebrew_fallback() -> Option> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| script_fallback(&hebrew_candidates(), HEBREW_PROBE)).clone() +} + fn script_fallback(candidates: &[PathBuf], probe: char) -> Option> { if std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") { return None; @@ -110,21 +119,53 @@ fn script_fallback(candidates: &[PathBuf], probe: char) -> Option> candidates.iter().find_map(|path| read(path, probe, MAX_BYTES)) } +/// macOS folders that hold Apple's own families, system folder first. On macOS 26.6 (Apple M5 +/// Max) `Kohinoor.ttc` is in `/System/Library/Fonts` while the Sangam MN faces are under +/// `Supplemental/`, so looking one family up in both folders stops a move by Apple from silently +/// dropping it — which is what happened to `Supplemental/Kohinoor.ttc`, listed by the code but +/// not present on that OS. `/Library/Fonts` is deliberately not listed here: it holds +/// compatibility symlinks into `Supplemental/`, and the one face wanted from it is named in full +/// below. On that machine each family below lives in exactly one of the two folders, so their +/// order is a deterministic choice rather than a tie-break that was ever exercised. +/// +/// Only [`macos_devanagari_candidates`] uses this today: in the Telugu, Sinhala and Tamil lists each +/// family is named once, in a single folder (some of those lists also name `/Library/Fonts` for the +/// Unicode face). Their first choices all exist on macOS 26.6, so nothing is dropping there now — the +/// same two-folder expansion would fit them, and is left for a change that tests it. +const MACOS_FONT_DIRS: [&str; 2] = ["/System/Library/Fonts", "/System/Library/Fonts/Supplemental"]; + +/// The Devanagari families this list tries, best first. Spelled as the files are on disk — +/// `Devanagari Sangam MN.ttc` has spaces, like the other Sangam MN families below. Apple ships at +/// least one more Devanagari-capable collection on macOS 26.6 (`Supplemental/ITFDevanagari.ttc`, +/// 10 faces), and this list does not try it. +const MACOS_DEVANAGARI_FAMILIES: &[&str] = &["DevanagariMT.ttc", "Devanagari Sangam MN.ttc", "Kohinoor.ttc"]; + +/// `families`, best first, each looked for in every folder of `dirs`. Split out from +/// [`macos_candidates`] so the macOS layout can be tested on a machine with no Apple fonts. +fn macos_families_in(dirs: &[&str], families: &[&str]) -> Vec { + families.iter().flat_map(|family| dirs.iter().map(move |dir| Path::new(dir).join(family))).collect() +} + +/// [`macos_families_in`] against the folders Apple actually uses. +fn macos_candidates(families: &[&str]) -> Vec { + macos_families_in(&MACOS_FONT_DIRS, families) +} + +/// The macOS Devanagari list: Apple's installed families, then the last-resort Unicode face. +/// `Arial Unicode.ttf` is listed from `/Library/Fonts`, where it is a symlink into +/// `Supplemental/` on current macOS, and is read only when no family above covers the script. +fn macos_devanagari_candidates() -> Vec { + let mut list = macos_candidates(MACOS_DEVANAGARI_FAMILIES); + list.push(PathBuf::from("/Library/Fonts/Arial Unicode.ttf")); + list +} + /// Well-known locations of Devanagari (Hindi) faces, best first. fn devanagari_candidates() -> Vec { if cfg!(windows) { ["Nirmala.ttf", "NirmalaB.ttf", "mangal.ttf", "aparaj.ttf", "utsah.ttf"].iter().map(|f| windows_fonts().join(f)).collect() } else if cfg!(target_os = "macos") { - [ - "/System/Library/Fonts/Supplemental/DevanagariMT.ttc", - "/System/Library/Fonts/Supplemental/DevanagariSangamMN.ttc", - "/System/Library/Fonts/Supplemental/Kohinoor.ttc", - "/System/Library/Fonts/Kohinoor.ttc", - "/Library/Fonts/Arial Unicode.ttf", - ] - .iter() - .map(PathBuf::from) - .collect() + macos_devanagari_candidates() } else { unix_fonts(&[ "google-noto/NotoSansDevanagariUI-Regular.ttf", @@ -227,6 +268,30 @@ fn tamil_candidates() -> Vec { } } +/// Well-known locations of Hebrew faces, best first. The general fallback does not cover it: +/// SF Arabic, Geeza Pro and Noto Sans Arabic have no Hebrew. +fn hebrew_candidates() -> Vec { + if cfg!(windows) { + ["segoeui.ttf", "arial.ttf", "tahoma.ttf", "david.ttf"].iter().map(|f| windows_fonts().join(f)).collect() + } else if cfg!(target_os = "macos") { + ["/System/Library/Fonts/SFHebrew.ttf", "/System/Library/Fonts/ArialHB.ttc", "/Library/Fonts/Arial Unicode.ttf"] + .iter() + .map(PathBuf::from) + .collect() + } else { + unix_fonts(&[ + "google-noto/NotoSansHebrew-Regular.ttf", + "google-noto-vf/NotoSansHebrew[wght].ttf", + "truetype/noto/NotoSansHebrew-Regular.ttf", + "noto/NotoSansHebrew-Regular.ttf", + "truetype/dejavu/DejaVuSans.ttf", + "TTF/DejaVuSans.ttf", + "dejavu/DejaVuSans.ttf", + "dejavu-sans-fonts/DejaVuSans.ttf", + ]) + } +} + /// Well-known locations of faces with broad script coverage, best first. fn candidates(need: Need) -> Vec { match need { @@ -398,8 +463,41 @@ mod tests { } } + /// `script_fallback` honours the documented installed-font switch, under which it returns + /// nothing before order is ever consulted — so both loader tests and the macOS oracle skip when + /// it is off, the way `tests/fonts.rs` skips for the same reason. Without this the suite goes red in exactly the + /// mode `cargo xtask screenshots` uses. + fn installed_faces_switched_off() -> bool { + std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") + } + + #[test] + fn the_first_readable_candidate_wins_not_the_last() { + // List order *is* the preference, so a loader that scanned back-to-front would pick a + // different face and nobody would notice. Two bundled faces both cover 'A' and differ in + // size, so the winner is identifiable by length alone. + if installed_faces_switched_off() { + eprintln!("skipping: PDFCRAFT_SYSTEM_FONTS=0 turns the loader off before order matters"); + return; + } + let dir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts")); + let (inter, mono) = (dir.join("Inter-Regular.ttf"), dir.join("JetBrainsMono-Regular.ttf")); + let size = |p: &Path| std::fs::metadata(p).expect("bundled face").len(); + assert_ne!(size(&inter), size(&mono), "the two fixtures must be tellable apart"); + let first = script_fallback(&[inter.clone(), mono.clone()], 'A').expect("Inter covers 'A'"); + assert_eq!(first.font.len() as u64, size(&inter), "the first readable candidate must win"); + let reversed = script_fallback(&[mono.clone(), inter], 'A').expect("JetBrains Mono covers 'A'"); + assert_eq!(reversed.font.len() as u64, size(&mono), "reversing the list must reverse the pick"); + } + #[test] fn a_script_fallback_is_the_first_candidate_with_the_probe() { + // This one calls `script_fallback` too, so it fails under the documented switch that + // published screenshots use — skip with a reason, as tests/fonts.rs does. + if installed_faces_switched_off() { + eprintln!("skipping: PDFCRAFT_SYSTEM_FONTS=0 turns the loader off"); + return; + } let inter = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf")); let candidates = [PathBuf::from("definitely/not/here.ttf"), inter]; assert!(script_fallback(&candidates, 'A').is_some()); @@ -407,11 +505,12 @@ mod tests { assert!(script_fallback(&candidates, DEVANAGARI_PROBE).is_none()); assert!(script_fallback(&candidates, SINHALA_PROBE).is_none()); assert!(script_fallback(&candidates, TAMIL_PROBE).is_none()); + assert!(script_fallback(&candidates, HEBREW_PROBE).is_none()); } #[test] fn script_candidates_are_absolute_font_files() { - for list in [devanagari_candidates(), telugu_candidates(), sinhala_candidates(), tamil_candidates()] { + for list in [devanagari_candidates(), telugu_candidates(), sinhala_candidates(), tamil_candidates(), hebrew_candidates()] { assert!(!list.is_empty()); assert!(list.iter().all(|p| p.is_absolute() || cfg!(windows)), "{list:?}"); assert!(list.iter().all(|p| p.extension().is_some_and(|e| e == "ttf" || e == "ttc")), "{list:?}"); @@ -427,4 +526,134 @@ mod tests { } } } + + /// Retune tripwires, deliberately separate from `the_macos_devanagari_list_is_apples_layout` below: these assert that three + /// constants still hold the values this module's contract depends on. They cannot fail on their + /// own — they exist so that editing one is a decision someone has to make twice. Each was + /// checked by deleting the pin and watching the matching regression go green again: raising + /// `MAX_BYTES` above Arial Unicode's ~23 MB, swapping the Devanagari probe for Arabic `U+0627` + /// (which that face also covers, so every other test stays green), and cutting `MAX_FACES` to 1. + /// The oracle in the macOS test below also depends on all three, and it is conditional on + /// installation — so on CI these are the only guard. + #[test] + fn the_load_constants_are_the_documented_contract() { + assert_eq!(MAX_BYTES, 32 << 20, "the non-Han installed-face size cap is this module's contract (Arial Unicode is ~23 MB)"); + assert_eq!(DEVANAGARI_PROBE, '\u{0915}', "Devanagari ka is what the Hindi candidates are probed for, not another script's letter"); + assert_eq!(MAX_FACES, 16, "how deep into a .ttc collection the loader looks; the macOS oracle trusts this depth"); + } + + /// The macOS Devanagari list, exactly: families best first, each tried in the system folder + /// before `Supplemental/`. This pins the spellings Apple uses on disk and the order that + /// decides which face wins. A missing space (`DevanagariSangamMN.ttc`), swapping the two + /// folders, or walking folder-major instead of family-major all fail here — and they fail on + /// every OS, because this test reads no filesystem. + #[test] + fn the_macos_devanagari_list_is_apples_layout() { + assert_eq!( + macos_devanagari_candidates(), + vec![ + PathBuf::from("/System/Library/Fonts/DevanagariMT.ttc"), + PathBuf::from("/System/Library/Fonts/Supplemental/DevanagariMT.ttc"), + PathBuf::from("/System/Library/Fonts/Devanagari Sangam MN.ttc"), + PathBuf::from("/System/Library/Fonts/Supplemental/Devanagari Sangam MN.ttc"), + PathBuf::from("/System/Library/Fonts/Kohinoor.ttc"), + PathBuf::from("/System/Library/Fonts/Supplemental/Kohinoor.ttc"), + PathBuf::from("/Library/Fonts/Arial Unicode.ttf"), + ] + ); + // Pin what the application actually consults, not just the helper that builds it. + #[cfg(target_os = "macos")] + assert_eq!(devanagari_candidates(), macos_devanagari_candidates(), "the app must consult the list this test pins"); + } + + #[test] + fn a_family_whose_name_has_spaces_is_still_found() { + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0); + let root = std::env::temp_dir().join(format!("pdfcraft-macos-fonts-{}-{nanos}", std::process::id())); + let supplemental = root.join("Supplemental"); + std::fs::create_dir_all(&supplemental).expect("build a temp macOS-shaped font tree"); + let dirs = [root.to_string_lossy().to_string(), supplemental.to_string_lossy().to_string()]; + let names: Vec<&str> = dirs.iter().map(|d| d.as_str()).collect(); + let inter: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let spaced = "Devanagari Sangam MN.ttc"; + std::fs::write(supplemental.join(spaced), inter).expect("place a face at the spaced path"); + let found = macos_families_in(&names, &[spaced]).iter().any(|p| read(p, 'A', MAX_BYTES).is_some()); + let _ = std::fs::remove_dir_all(&root); + assert!(found, "a family named with spaces did not resolve under {dirs:?}"); + } + + /// On a Mac, where Apple's fonts are installed: every family folder that really exists is + /// reachable from the candidate list, and something in the list covers Devanagari. A family + /// Apple no longer ships is reported rather than failed — this list exists precisely for the case + /// where one of them is missing, with one exception: if *nothing* here covers Devanagari the test + /// fails loudly, because a machine with no face to check cannot validate the list at all. That + /// leniency is not a guess: Apple's "Fonts included with macOS Tahoe" page (support.apple.com/122869) + /// titles its list "Installed or downloadable fonts" and says most install automatically while + /// others download on demand via Font Book — without marking which is which, so no entry in these + /// lists can be assumed present on a given Mac. CI has no macOS runner (`.github/workflows/ci.yml` + /// keeps the org's Mac slots for release builds), so this runs on contributors' and + /// maintainers' machines. + #[cfg(target_os = "macos")] + #[test] + fn every_installed_devanagari_face_is_reachable_on_this_mac() { + if installed_faces_switched_off() { + eprintln!("skipping on this machine: PDFCRAFT_SYSTEM_FONTS=0 turns installed faces off"); + return; + } + let list = devanagari_candidates(); + for family in MACOS_DEVANAGARI_FAMILIES { + // Same rule as the last resort below: an entry that exists counts, even a link whose + // target has gone — "present but unresolved" is precisely the dead-path defect this change + // is about, and filtering on `is_file()` would quietly reclassify it as "not installed". + // Absence is tolerated; a broken entry is not. + let present: Vec = + MACOS_FONT_DIRS.iter().map(|dir| Path::new(dir).join(family)).filter(|p| std::fs::symlink_metadata(p).is_ok()).collect(); + if present.is_empty() { + eprintln!("{family}: not installed on this machine, so the list offers nothing that exists — fine, and reported only"); + continue; + } + for path in &present { + assert!( + path.is_file(), + "{family}: {path:?} exists but does not resolve — its target was moved or removed, so this candidate path has gone dead" + ); + assert!(list.contains(path), "{path:?} is installed here but the candidate list skips it: {list:?}"); + } + } + assert!(script_fallback(&devanagari_candidates(), DEVANAGARI_PROBE).is_some(), "no installed face on this Mac covers Devanagari"); + // And the function callers use, not just its inputs: asserting *some* face loads is not + // enough, because both lists end at the same last-resort Unicode face and would rescue a + // mix-up. The app-facing function must land on the same bytes, and the same face within + // them, as this list produces. + let direct = script_fallback(&devanagari_candidates(), DEVANAGARI_PROBE).expect("the list above resolves"); + let cached = devanagari_fallback().expect("devanagari_fallback() resolves where these faces are installed"); + assert_eq!(cached.font.len(), direct.font.len(), "devanagari_fallback() must load the face this list points at, not another script's"); + assert_eq!(cached.index, direct.index, "and the same face within that file"); + // The advertised last resort is the biggest file on the list and reaches its target through + // a compatibility symlink in `/Library/Fonts`, a folder installers and users may modify — so + // it is optional in the same sense the families above are, and a Mac without it must not go + // red for an unrelated reason. But `is_file()` follows symlinks: a link whose target has + // moved reads as "no file", which is exactly the dead-path defect this change exists to + // catch. So gate on the entry existing (lstat), then require that it resolves, then check + // size cap and coverage. `MAX_BYTES` is what the loader applies to non-Han scripts; the path + // comes from the list itself rather than being written out again, so this oracle cannot + // drift from what production offers. + let last_resort = list.last().expect("the macOS Devanagari list always ends at the Unicode face").clone(); + assert!( + last_resort.to_string_lossy().ends_with("Arial Unicode.ttf"), + "this block checks the Unicode last resort, so the list must still end at it: {last_resort:?}" + ); + if std::fs::symlink_metadata(&last_resort).is_ok() { + assert!( + last_resort.is_file(), + "{last_resort:?} exists but does not resolve — its target was moved or removed, so this path has gone dead" + ); + assert!( + read(&last_resort, DEVANAGARI_PROBE, MAX_BYTES).is_some(), + "the last-resort Unicode face is installed here but did not resolve and load under MAX_BYTES" + ); + } else { + eprintln!("{last_resort:?} is not installed on this machine — optional to the loader, so nothing to check"); + } + } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 3f43a03dcc..2023124d45 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -224,6 +224,8 @@ pub const SYSTEM_TELUGU_FALLBACK: &str = "system-telugu-fallback"; pub const SYSTEM_SINHALA_FALLBACK: &str = "system-sinhala-fallback"; /// The name of the installed Tamil face [`installed_font_definitions`] may add. pub const SYSTEM_TAMIL_FALLBACK: &str = "system-tamil-fallback"; +/// The name of the installed Hebrew face [`installed_font_definitions`] may add. +pub const SYSTEM_HEBREW_FALLBACK: &str = "system-hebrew-fallback"; /// What [`install_fonts_for`] installs: [`font_definitions_for`], then, on desktop, one face /// already installed on this machine. It only draws characters no embedded face has. @@ -236,9 +238,9 @@ pub const SYSTEM_TAMIL_FALLBACK: &str = "system-tamil-fallback"; /// - Otherwise it is the last fallback of every family (an Arabic file name in a build without /// craft-fonts). /// -/// Installed Devanagari, Telugu, Sinhala and Tamil faces, when there are, end every family just -/// before that one, so Hindi, Telugu (#1531), Sinhala and Tamil (#1688) text, such as a file -/// name, draws in a build without such faces. +/// Installed Devanagari, Telugu, Sinhala, Tamil and Hebrew faces, when there are, end every +/// family just before that one, so Hindi, Telugu (#1531), Sinhala and Tamil (#1688) text, such as +/// a file name, and Hebrew file names (#586) draw in a build without such faces. /// /// `PDFCRAFT_SYSTEM_FONTS=0` leaves them out. pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions { @@ -253,6 +255,7 @@ pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions { (SYSTEM_TELUGU_FALLBACK, crate::system_fonts::telugu_fallback()), (SYSTEM_SINHALA_FALLBACK, crate::system_fonts::sinhala_fallback()), (SYSTEM_TAMIL_FALLBACK, crate::system_fonts::tamil_fallback()), + (SYSTEM_HEBREW_FALLBACK, crate::system_fonts::hebrew_fallback()), ] { let Some(data) = data else { continue }; fonts.font_data.insert(name.to_owned(), data); diff --git a/crates/ui-egui/tests/fonts.rs b/crates/ui-egui/tests/fonts.rs index 9cfa36c8ab..ac947d122a 100644 --- a/crates/ui-egui/tests/fonts.rs +++ b/crates/ui-egui/tests/fonts.rs @@ -8,6 +8,7 @@ const JAPANESE: &str = "日本語の文字"; const CHINESE: &str = "简体中文欢迎"; const ARABIC: &str = "واحد اثنين"; const TELUGU: &str = "తెలుగు ఫైల్"; +const HEBREW: &str = "ייפוי כח.pdf"; fn families() -> Vec { vec![FontId::proportional(13.0), FontId::monospace(13.0), theme::medium(13.0), theme::semibold(17.0)] @@ -219,6 +220,29 @@ fn system_fallback_covers_sinhala_and_tamil() { } } +/// On a machine with an installed Hebrew font, the installed definitions include it and a +/// Hebrew file name has glyphs in the document tab and the recent files (#586). macOS always +/// ships Arial Hebrew and SF Hebrew, so there the test never skips. +#[test] +fn system_fallback_covers_hebrew() { + assert!(!theme::font_definitions().font_data.contains_key(theme::SYSTEM_HEBREW_FALLBACK)); + let defs = theme::installed_font_definitions(theme::CjkPreference::Default); + let off = std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0"); + if !defs.font_data.contains_key(theme::SYSTEM_HEBREW_FALLBACK) && (off || !cfg!(target_os = "macos")) { + eprintln!("skipping system_fallback_covers_hebrew: no installed Hebrew fallback font (or PDFCRAFT_SYSTEM_FONTS=0)"); + assert!(defs.families.values().all(|stack| !stack.iter().any(|n| n == theme::SYSTEM_HEBREW_FALLBACK))); + return; + } + for (family, stack) in &defs.families { + assert_eq!(stack.iter().filter(|n| *n == theme::SYSTEM_HEBREW_FALLBACK).count(), 1, "{family:?}: {stack:?}"); + } + let mut fonts = Fonts::new(TextOptions::default(), defs); + for id in families() { + assert!(fonts.has_glyphs(&id, HEBREW), "{id:?} lacks {HEBREW}"); + } + assert!(layout_widths(&mut fonts, HEBREW).iter().all(|w| w.is_finite() && *w > 0.0)); +} + /// The Han characters of a catalog's translations. fn catalog_han(catalog: &str) -> String { let mut han: Vec = catalog diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 4e9e62a191..ed17c08d96 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -24,14 +24,21 @@ page. Select **Keep source fonts** to preserve each run's original face. A match completes its subsets automatically, including new Chinese characters. When the PDF itself embeds the complete program of a paragraph's font (not a subset) and its licence allows editable embedding, a character the font's encoding can't show is drawn with that same program. A -character no available face has is refused with an error that names it. Choosing another face +character no available face has is refused with an error that names it, and a CJK character +without a craft-fonts build says so. Replacement text takes the craft-fonts Japanese faces when +they can draw all of it, and the bundled Inter face (`assets/fonts`) for the Latin, Greek and +Cyrillic letters they lack (Uzbek `қизил`, Kazakh `әже`): that face needs no build input, so +these work in every build, the web one included. Choosing another face is explicit. Fonts with restricted embedding, unsupported collections, excluded families or no open-licence declaration are refused. Interface fallback fonts are never exported. The full selected program, glyph mappings, widths and Unicode text are embedded in the PDF. Saving and reopening therefore needs no locally installed font. Editing the same text again reuses the font resource the page already has, and every resource made from one program -shares a single copy of it. Original subset bytes are +shares a single copy of it. The Type 3 fonts generated for Japanese or Hebrew characters the +page's fonts lack are made anew by each edit; after an edit, those nothing on the page shows any +more leave the page's resources, unless some content that could name them can't be read. +Original subset bytes are preserved. Optional matching faces from `CRAFT_FONTS_DIR` can also complete source subsets; this works only when that build input actually contains the full face. This change does not add a font binary or change the release font pin. Fonts shared by releases belong in diff --git a/docs/roadmap.md b/docs/roadmap.md index 201f90126c..e6f81d9fe6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,6 +7,7 @@ > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (PR #3348 review: retain short tracked punctuation in gap estimates; estimates unchanged) +> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M2, #3601: missing spacing glyphs in non-embedded Japanese CID substitutes draw as blanks while PDF widths and Unicode stay unchanged; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** patch (M2 #3086: dotted TOC leaders no longer hide word spaces; estimates unchanged) @@ -128,6 +129,7 @@ total matches the parity estimate. | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. | @@ -213,6 +215,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-20 · minor:** M2/#3601: substitute-font spacing aliases preserve blank ink, original `/W` and `/DW` advances, and Unicode extraction. Synthetic renderer regressions cover the reported Japanese CID fixture. Estimates unchanged. + **2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. | Date | Change | Summary | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e062012fad..f36841fe9f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1866,8 +1866,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row"] -notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools"] +notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" [[feature]] id = "edit.text-reflow" diff --git a/vendor/README.md b/vendor/README.md index eb47681632..f472ba4a0f 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -17,7 +17,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it; (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` |; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it; (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` |; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name` | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` |; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover` | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` |; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover` @@ -89,3 +89,12 @@ Coverage: `text::tests::clipping_mode_filter_keeps_titles_and_excludes_ocr` (pla and ActualText filtered extraction), and `text::tests::clipping_mode_text_is_extracted_and_searchable_like_other_rendering_modes` (extraction, search, glyph geometry, clip-through pixels and no-paint pixels). + +Missing spacing glyphs (#3601): in `hayro-interpret` `Type0Font::map_via_unicode`, +an OpenType substitute uses its ordinary-space glyph only when its own charmap +lacks a spacing character (including Adobe-Japan1 U+2002). Native glyphs and +variation forms retain priority; embedded fonts, PDF advances and Unicode decoding +are unchanged. Renderer regressions `japanese_substitute_missing_spaces_draw_no_ink` +and `japanese_substitute_spaces_keep_pdf_widths_and_unicode` use the synthetic +MS-Mincho / UniJIS-UCS2-HW-H fixture with craft-fonts, including `/W` and `/DW` +placement controls. diff --git a/vendor/hayro-interpret/src/font/blob.rs b/vendor/hayro-interpret/src/font/blob.rs index 6eabc26b9b..43b540cc9d 100644 --- a/vendor/hayro-interpret/src/font/blob.rs +++ b/vendor/hayro-interpret/src/font/blob.rs @@ -13,7 +13,7 @@ use skrifa::raw::{FontData as ReadFontData, FontRead}; use skrifa::{FontRef, GlyphId, MetadataProvider, OutlineGlyphCollection}; use std::collections::HashMap; use std::fmt::{Debug, Formatter}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use yoke::{Yoke, Yokeable}; type FontData = Arc + Send + Sync>; @@ -50,7 +50,11 @@ impl Type1FontBlob { /// A font blob for CFF-based fonts. #[derive(Clone)] -pub(crate) struct CffFontBlob(Arc); +pub(crate) struct CffFontBlob(Arc, Arc>); + +/// PdfCraft patch: (see vendor/README.md hayro-interpret (50)) glyph name to glyph id, built on +/// first use. +type GlyphNames = HashMap, GlyphId>; impl Debug for CffFontBlob { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { @@ -84,7 +88,7 @@ impl CffFontBlob { }); let _ = (cff, charset, subfonts); - Some(Self(Arc::new(yoke))) + Some(Self(Arc::new(yoke), Arc::new(OnceLock::new()))) } pub(crate) fn font_data(&self) -> FontData { @@ -134,11 +138,22 @@ impl CffFontBlob { } pub(crate) fn glyph_index_by_name(&self, name: &str) -> Option { - // TODO: This is probably slow to do repeatedly? - self.charset()?.iter().find_map(|(gid, sid)| { - let bytes = self.0.as_ref().get().cff.string(sid)?; - (bytes == name.as_bytes()).then_some(gid) - }) + // Called for every character code of a font, so the charset is read once. The first + // glyph of a name wins, as it did when the charset was searched in order. + self.1 + .get_or_init(|| { + let mut names = GlyphNames::new(); + if let Some(charset) = self.charset() { + for (gid, sid) in charset.iter() { + if let Some(bytes) = self.0.as_ref().get().cff.string(sid) { + names.entry(bytes.into()).or_insert(gid); + } + } + } + names + }) + .get(name.as_bytes()) + .copied() } pub(crate) fn glyph_index_by_cid(&self, cid: u16) -> Option { @@ -179,11 +194,14 @@ impl OpenTypeFontBlob { let f = FontRef::from_index(data.as_ref().as_ref(), index).ok()?; // Reject fonts with invalid post table version, fixes pdf.js issue 9462. Not sure if there // is a better fix, for some reason skrifa accepts the font which is completely invalid. + // PdfCraft patch: also accept 1.0, 2.0 and 3.0 with the two 16-bit halves swapped + // (`00 00 00 03` for 3.0), as ScanSoft PDF Create! writes it into every font it embeds + // (#2946). Rejecting those fonts drew their text with a substitute that has no such glyphs. let invalid = f.post().is_ok_and(|p| { !matches!( p.version().to_major_minor(), (1, 0) | (2, 0) | (2, 5) | (3, 0) - ) + ) && !matches!(p.version().to_be_bytes(), [0, 0, 0, 1..=3]) }); if invalid { @@ -253,6 +271,27 @@ impl OpenTypeFontBlob { return glyph_names; }; + // PdfCraft patch: a version written with its halves swapped (`00 00 00 02`, accepted in + // `new`) is read as the version it means, from a copy of the table with the version + // written correctly: read-fonts reads a 2.0 table's name index only under version 2.0, + // so otherwise its glyph names were never found (#2946). + let repaired = match post.version().to_be_bytes() { + [0, 0, 0, n @ 1..=3] => self + .font_ref() + .table_data(skrifa::raw::types::Tag::new(b"post")) + .map(|data| { + let mut bytes = data.as_bytes().to_vec(); + if let Some(version) = bytes.get_mut(..4) { + version.copy_from_slice(&[0, n, 0, 0]); + } + bytes + }), + _ => None, + }; + let repaired_post = repaired.as_deref().and_then(|b| { + skrifa::raw::tables::post::Post::read(ReadFontData::new(b)).ok() + }); + let post = repaired_post.as_ref().unwrap_or(&post); match post.version().to_major_minor() { (1, 0) => { for (gid, name) in DEFAULT_GLYPH_NAMES.iter().enumerate() { @@ -333,3 +372,101 @@ struct CFFYoke<'a> { charset: Option>, subfonts: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + /// A CFF font with glyphs `.notdef`, `Alpha`, `Alpha` (a duplicate name) and `Beta`. + fn cff_with_names() -> Vec { + fn index(items: &[&[u8]]) -> Vec { + let mut out = (items.len() as u16).to_be_bytes().to_vec(); + if items.is_empty() { + return out; + } + out.push(1); + let mut offset = 1u8; + out.push(offset); + for item in items { + offset += item.len() as u8; + out.push(offset); + } + for item in items { + out.extend_from_slice(item); + } + out + } + // Operands use the 5-byte form so the Top DICT has a fixed size. + fn int(value: usize) -> Vec { + let mut out = vec![29]; + out.extend_from_slice(&(value as u32).to_be_bytes()); + out + } + fn build_top(charset: usize, char_strings: usize, private: usize) -> Vec { + let mut dict = Vec::new(); + dict.extend(int(charset)); + dict.push(15); + dict.extend(int(char_strings)); + dict.push(17); + dict.extend(int(0)); + dict.extend(int(private)); + dict.push(18); + dict + } + let header = vec![1, 0, 4, 1]; + let names = index(&[b"T"]); + let strings = index(&[b"Alpha", b"Beta"]); + let subrs = index(&[]); + // Standard strings take SIDs 0..=390, so the first custom string is 391. + let charset = vec![0, 0x01, 0x87, 0x01, 0x87, 0x01, 0x88]; + let char_strings = index(&[&[14], &[14], &[14], &[14]]); + // The Top DICT has a fixed size because every operand takes the 5-byte form. + let top_len = index(&[&build_top(0, 0, 0)]).len(); + let charset_at = header.len() + names.len() + top_len + strings.len() + subrs.len(); + let char_strings_at = charset_at + charset.len(); + let private_at = char_strings_at + char_strings.len(); + let dict = build_top(charset_at, char_strings_at, private_at); + let top = index(&[&dict]); + let mut out = header; + for part in [names, top, strings, subrs, charset, char_strings] { + out.extend(part); + } + out + } + + /// The charset search `glyph_index_by_name` replaced. + fn linear(blob: &CffFontBlob, name: &str) -> Option { + blob.charset()?.iter().find_map(|(gid, sid)| { + let bytes = blob.0.as_ref().get().cff.string(sid)?; + (bytes == name.as_bytes()).then_some(gid) + }) + } + + #[test] + fn glyph_names_resolve_to_the_first_glyph_with_that_name() { + let data: FontData = Arc::new(cff_with_names()); + let blob = CffFontBlob::new(data).expect("the test font parses"); + assert!(blob.1.get().is_none(), "the name map is built on first use"); + assert_eq!(blob.glyph_index_by_name("Alpha"), Some(GlyphId::new(1))); + assert_eq!(blob.glyph_index_by_name("Beta"), Some(GlyphId::new(3))); + assert_eq!(blob.glyph_index_by_name("Missing"), None); + assert_eq!(blob.glyph_index_by_name(""), None); + for name in ["Alpha", "Beta", "Missing", ".notdef", ""] { + assert_eq!( + blob.glyph_index_by_name(name), + linear(&blob, name), + "{name:?}" + ); + // The second lookup is answered from the stored map. + assert_eq!( + blob.glyph_index_by_name(name), + linear(&blob, name), + "{name:?}" + ); + } + assert!(blob.1.get().is_some(), "the lookups built the name map"); + let shared = blob.clone(); + assert!(Arc::ptr_eq(&blob.1, &shared.1), "clones share one name map"); + assert_eq!(shared.glyph_index_by_name("Alpha"), Some(GlyphId::new(1))); + } +} diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index 1451359252..e23b2433d3 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -275,7 +275,13 @@ impl Type0Font { { return Some(glyph); } - charmap.map(character) + // PdfCraft patch: a substitute may lack a spacing character (#3601; e.g. + // Adobe-Japan1's U+2002). Draw its ordinary space instead of .notdef; + // the PDF's widths and Unicode mapping still determine advance and text. + charmap.map(character).or_else(|| match character { + '\u{00a0}' | '\u{2000}'..='\u{200a}' | '\u{202f}' | '\u{205f}' | '\u{3000}' => charmap.map(' '), + _ => None, + }) } FontType::Cff(c) => { // Map codepoint to glyph name via AFL, and then look it up. diff --git a/vendor/hayro-interpret/src/font/standard_font.rs b/vendor/hayro-interpret/src/font/standard_font.rs index 1f386a2cec..5941160f77 100644 --- a/vendor/hayro-interpret/src/font/standard_font.rs +++ b/vendor/hayro-interpret/src/font/standard_font.rs @@ -445,7 +445,8 @@ impl StandardKind { /// PdfCraft patch: the outline of a glyph from [`Self::composite_glyph`] — the base letter, /// then the accent centred on it. An accent above a letter taller than `x` is raised by the /// difference (the stand-in's accents sit on lowercase letters) and, in an italic font, - /// moved along the slant; an ogonek hangs from the letter's right edge. + /// moved along the slant; an ogonek hangs from the letter's right edge; a comma below is + /// the font's comma, smaller, under the baseline. fn composite_outline(&self, glyph: GlyphId) -> Option { let code = u8::try_from(glyph.to_u32().checked_sub(COMPOSITE_GLYPH_BASE)?).ok()?; let (base, accent, place) = accented_parts(self.code_to_ps_name(code)?)?; @@ -455,13 +456,27 @@ impl StandardKind { .map(|g| self.base_font_blob.outline_glyph(g)) }; let mut path = outline(base)?; - let accent = outline(accent)?; + let mut accent = outline(accent)?; if path.elements().is_empty() || accent.elements().is_empty() { return Some(path); } + if place == Place::CommaBelow { + accent = Affine::scale(COMMA_BELOW_SCALE) * accent; + } let (b, a) = (path.bounding_box(), accent.bounding_box()); let (dx, dy) = match place { Place::Below => (b.center().x - a.center().x, 0.0), + Place::CommaBelow => { + // Its top a little below the baseline (or the letter's overshoot below it), and + // in an italic font moved back along the slant by as much as it is lowered. + let dy = b.y0.min(0.0) - COMMA_BELOW_GAP * a.height() - a.y1; + let slant = if self.base_font.is_italic() { + dy * ITALIC_ACCENT_SLANT + } else { + 0.0 + }; + (b.center().x - a.center().x + slant, dy) + } Place::Ogonek => (b.x1 - a.x1, 0.0), Place::Above => { let x_top = outline("x") @@ -582,12 +597,21 @@ const COMPOSITE_GLYPH_BASE: u32 = 0x0100_0000; /// an italic stand-in (tan 15°; the standard italics lean 12° to 16°). const ITALIC_ACCENT_SLANT: f64 = 0.27; +/// PdfCraft patch: the size of a comma accent against the font's comma. A full-size comma +/// moved under the baseline reaches past the descenders and into the line below. +const COMMA_BELOW_SCALE: f64 = 0.75; + +/// PdfCraft patch: the gap between the baseline and a comma accent, as a share of its height. +const COMMA_BELOW_GAP: f64 = 0.15; + /// Where an accent goes on its base letter. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Place { Above, Below, Ogonek, + /// The font's comma, smaller, under the letter (the stand-ins have no `commaaccent`). + CommaBelow, } /// PdfCraft patch: the stand-ins for the standard fonts (PDFium's Foxit fonts) carry only the @@ -596,7 +620,11 @@ enum Place { /// iText 2 writes Turkish text with Times New Roman left unembedded) drew nothing for them. Each of /// these Latin Extended-A letters is a base letter the stand-ins have plus one of their accents, /// as Type 1 `seac` composites and the composites of the standard-14 AFM files are built. -/// Letters whose accent isn't a plain one (ď ľ ť and the comma accents) aren't here. +/// The comma-below letters of Romanian (ș ț) and Latvian (ķ ļ ņ ŗ Ģ) are the base letter and the +/// font's comma, since the stand-ins have no `commaaccent` either; without them a form laid out +/// from its XFA template drew Romanian ș as a blank and ț with a cedilla. Letters whose accent +/// isn't a plain one (ď ľ ť, whose caron is an apostrophe beside the letter, and ģ, whose comma +/// is turned and above it) aren't here. const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[ ("Amacron", "A", "macron", Place::Above), ("amacron", "a", "macron", Place::Above), @@ -628,6 +656,7 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[ ("Gbreve", "G", "breve", Place::Above), ("gbreve", "g", "breve", Place::Above), ("Gdotaccent", "G", "dotaccent", Place::Above), + ("Gcommaaccent", "G", "comma", Place::CommaBelow), ("gdotaccent", "g", "dotaccent", Place::Above), ("Hcircumflex", "H", "circumflex", Place::Above), ("hcircumflex", "h", "circumflex", Place::Above), @@ -641,10 +670,16 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[ ("iogonek", "i", "ogonek", Place::Ogonek), ("Idotaccent", "I", "dotaccent", Place::Above), ("Jcircumflex", "J", "circumflex", Place::Above), + ("Kcommaaccent", "K", "comma", Place::CommaBelow), + ("kcommaaccent", "k", "comma", Place::CommaBelow), ("Lacute", "L", "acute", Place::Above), ("lacute", "l", "acute", Place::Above), + ("Lcommaaccent", "L", "comma", Place::CommaBelow), + ("lcommaaccent", "l", "comma", Place::CommaBelow), ("Nacute", "N", "acute", Place::Above), ("nacute", "n", "acute", Place::Above), + ("Ncommaaccent", "N", "comma", Place::CommaBelow), + ("ncommaaccent", "n", "comma", Place::CommaBelow), ("Ncaron", "N", "caron", Place::Above), ("ncaron", "n", "caron", Place::Above), ("Omacron", "O", "macron", Place::Above), @@ -655,6 +690,8 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[ ("ohungarumlaut", "o", "hungarumlaut", Place::Above), ("Racute", "R", "acute", Place::Above), ("racute", "r", "acute", Place::Above), + ("Rcommaaccent", "R", "comma", Place::CommaBelow), + ("rcommaaccent", "r", "comma", Place::CommaBelow), ("Rcaron", "R", "caron", Place::Above), ("rcaron", "r", "caron", Place::Above), ("Sacute", "S", "acute", Place::Above), @@ -663,8 +700,12 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[ ("scircumflex", "s", "circumflex", Place::Above), ("Scedilla", "S", "cedilla", Place::Below), ("scedilla", "s", "cedilla", Place::Below), + ("Scommaaccent", "S", "comma", Place::CommaBelow), + ("scommaaccent", "s", "comma", Place::CommaBelow), ("Tcedilla", "T", "cedilla", Place::Below), ("tcedilla", "t", "cedilla", Place::Below), + ("Tcommaaccent", "T", "comma", Place::CommaBelow), + ("tcommaaccent", "t", "comma", Place::CommaBelow), ("Tcaron", "T", "caron", Place::Above), ("Utilde", "U", "tilde", Place::Above), ("utilde", "u", "tilde", Place::Above), @@ -688,8 +729,28 @@ const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[ ("zdotaccent", "z", "dotaccent", Place::Above), ]; +/// PdfCraft patch: the comma-below letters of [`ACCENTED_LETTERS`] by character. The glyph list +/// names the Latvian ones `Kcedilla`… (their comma is a cedilla in Unicode's names), has no name +/// for Ț ț, and gives `Tcommaaccent` and `tcommaaccent` to Ţ ţ, which keep their cedilla. +const COMMA_BELOW_BY_CHAR: &[(char, &str)] = &[ + ('\u{0122}', "Gcommaaccent"), + ('\u{0136}', "Kcommaaccent"), + ('\u{0137}', "kcommaaccent"), + ('\u{013B}', "Lcommaaccent"), + ('\u{013C}', "lcommaaccent"), + ('\u{0145}', "Ncommaaccent"), + ('\u{0146}', "ncommaaccent"), + ('\u{0156}', "Rcommaaccent"), + ('\u{0157}', "rcommaaccent"), + ('\u{0218}', "Scommaaccent"), + ('\u{0219}', "scommaaccent"), + ('\u{021A}', "Tcommaaccent"), + ('\u{021B}', "tcommaaccent"), +]; + /// PdfCraft patch: the base letter, accent and placement of an accented letter in -/// [`ACCENTED_LETTERS`], by glyph name (`uni011E` and `u011E` forms too). +/// [`ACCENTED_LETTERS`], by glyph name (`uni011E` and `u011E` forms too). A comma-below letter +/// is found by its character ([`COMMA_BELOW_BY_CHAR`]), so `/uni0136` and `/Kcedilla` are Ķ too. fn accented_parts(name: &str) -> Option<(&'static str, &'static str, Place)> { let name = normalized_glyph_name(name); let by_name = |n: &str| { @@ -698,5 +759,11 @@ fn accented_parts(name: &str) -> Option<(&'static str, &'static str, Place)> { .find(|(letter, ..)| *letter == n) .map(|&(_, base, accent, place)| (base, accent, place)) }; - by_name(name).or_else(|| by_name(glyph_names::get_reverse(glyph_name_to_unicode(name)?)?)) + by_name(name).or_else(|| { + let c = glyph_name_to_unicode(name)?; + match COMMA_BELOW_BY_CHAR.iter().find(|(letter, _)| *letter == c) { + Some((_, comma_below)) => by_name(comma_below), + None => by_name(glyph_names::get_reverse(c)?), + } + }) }