mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 11:37:59 -06:00
Merge PR #2487: M7: drop the fallback fonts a text edit leaves unused
This commit is contained in:
@@ -1234,6 +1234,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<String> =
|
||||
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<u8>, Dict)> {
|
||||
let p = pdfcraft_model::pages(doc).swap_remove(0);
|
||||
|
||||
+89
-2
@@ -1472,6 +1472,89 @@ fn fallback_names(face: &CraftFont, hebrew: bool) -> (&'static str, &'static str
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Vec<u8>> =
|
||||
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<u8>> = 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<std::collections::HashSet<Vec<u8>>> {
|
||||
let mut streams: Vec<Object> = 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<Dict> = 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 {
|
||||
@@ -1900,7 +1983,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<LineEdit, EditError> {
|
||||
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
|
||||
@@ -2235,7 +2320,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<LineEdit, EditError> {
|
||||
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).
|
||||
|
||||
@@ -35,7 +35,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
|
||||
|
||||
Reference in New Issue
Block a user