From e0ef098381f6e99a2ea02b21289c58a768668480 Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:13:34 +0500 Subject: [PATCH] M7: drop the fallback fonts a text edit leaves unused Each edit that draws Japanese or Hebrew characters the page's fonts lack generates new Type 3 fonts with the outlines of every character it draws. Editing the paragraph again generated new ones and left the old ones in the page's resources, so every edit added a set of glyph outlines to the file. After replace_line and rewrite_block, the page's generated fallback fonts (Type 3, /Name /PCJapanese or /PCHebrew) that no Tf selects any more are removed from its own font dictionary. The content read for this is the page's streams, every form XObject it can reach that has no resources of its own (those use the page's, ISO 32000-1 7.8.3), and the glyph procedures of Type 3 fonts without resources. If any of those can't be decoded strictly, nothing is removed. Registered fonts (PCFull) and the file's own fonts are never touched. Tests: a page with an unused generated font, one its content selects, one only a resource-less form selects and a file's Type 3 font that only shares the name prefix keeps all but the first after a rewrite, and keeps everything when such a form can't be read. Rewriting Japanese text twice (with craft-fonts) leaves only the second rewrite's fonts. The first test fails without this change. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/tests.rs | 81 +++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 91 ++++++++++++++++++++++++++++++++++- docs/editing-existing-text.md | 5 +- 3 files changed, 174 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df..00832082f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1109,6 +1109,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); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 9c83cf9a3..883051d02 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -1311,6 +1311,89 @@ fn is_type3_fallback(font: &Dict) -> bool { && font.name(b"Name").is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes()) } +/// 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, and the Japanese or Hebrew fallback face it subsets, if /// any) continues `line`: the same font resource, or another subset of the same face. fn same_font(line: &TextLine, font: &[u8], face: Option<&str>) -> bool { @@ -1689,7 +1772,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) } /// [`replace_line`] with the most unique characters one generated font may hold (tests lower it). @@ -1980,7 +2065,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/docs/editing-existing-text.md b/docs/editing-existing-text.md index e8bb03b9a..6f348b99f 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -31,7 +31,10 @@ no open-licence declaration are refused. Interface fallback fonts are never expo 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