mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 14:41:51 +00:00
Type 3 cleanup reads only drawn appearances, and keeps the fonts they select
An annotation's /AP can hold entries that no reader draws. Only the normal, rollover and down appearances (N, R, D) now keep a Type 3 glyph, so an extra entry such as /X can no longer keep a redacted glyph program in the saved file. Rewriting a page also dropped a Type 3 font that only an annotation appearance without /Resources selects, although that appearance draws through the page's resources. The page prune now reads those appearances too. It reads them only when the page lists a Type 3 font, so other files are not refused over an appearance it cannot parse. Two tests, both failing without this change.
This commit is contained in:
@@ -884,7 +884,7 @@ fn xobject(
|
||||
out.error = Some(error);
|
||||
return None;
|
||||
}
|
||||
if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content) {
|
||||
if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content, None) {
|
||||
out.error = Some(error);
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -626,7 +626,7 @@ fn redact_page_content(
|
||||
pi,
|
||||
)?;
|
||||
retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?);
|
||||
type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?;
|
||||
type3::prune_type3_from_resources(doc, &mut res, &cleaned_data, Some(&page.dict))?;
|
||||
doc.update_dict(page.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Array(new_list));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
|
||||
@@ -424,7 +424,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
}
|
||||
retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?);
|
||||
crate::type3::prune_type3_from_resources(doc, &mut res, &page_content)?;
|
||||
crate::type3::prune_type3_from_resources(doc, &mut res, &page_content, Some(&page.dict))?;
|
||||
doc.update_dict(page.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Array(new_list));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
|
||||
@@ -332,6 +332,66 @@ fn a_type3_glyph_an_annotation_appearance_shows_is_kept() {
|
||||
assert!(full_save_holds(&doc, b"PAGE_GLYPH_C"), "the glyph the page still shows was unlinked");
|
||||
}
|
||||
|
||||
/// The same annotation also has an `/AP` entry no reader draws (`/X`), which shows the redacted
|
||||
/// glyph A. It keeps nothing: A's program goes, and B, which the normal appearance shows, stays.
|
||||
#[test]
|
||||
fn a_type3_glyph_only_an_undrawn_appearance_entry_shows_is_unlinked() {
|
||||
let mut doc = pdf(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 300] /Contents 4 0 R /Resources 5 0 R /Annots [7 0 R] >>".to_vec(),
|
||||
stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET"),
|
||||
b"<< /Font << /T3 6 0 R >> >>".to_vec(),
|
||||
type3_abc(10),
|
||||
b"<< /Type /Annot /Subtype /FreeText /Rect [150 200 250 220] /P 3 0 R /DA (/T3 10 Tf 0 g) /AP << /N 8 0 R /X 9 0 R >> >>".to_vec(),
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20] /Resources << /Font << /T3 6 0 R >> >>", b"BT /T3 10 Tf 2 5 Td (B) Tj ET"),
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20] /Resources << /Font << /T3 6 0 R >> >>", b"BT /T3 10 Tf 2 5 Td (A) Tj ET"),
|
||||
stream("", b"% REDACTED_GLYPH_A"),
|
||||
stream("", b"% APPEARANCE_GLYPH_B"),
|
||||
stream("", b"% PAGE_GLYPH_C"),
|
||||
]);
|
||||
mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], "");
|
||||
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1);
|
||||
assert!(!full_save_holds(&doc, b"REDACTED_GLYPH_A"), "an undrawn appearance entry kept the redacted glyph program");
|
||||
assert!(full_save_holds(&doc, b"APPEARANCE_GLYPH_B"), "the glyph the normal appearance shows was unlinked");
|
||||
assert!(full_save_holds(&doc, b"PAGE_GLYPH_C"), "the glyph the page still shows was unlinked");
|
||||
}
|
||||
|
||||
/// An appearance without /Resources shows glyph B of a second Type 3 font, which the page lists
|
||||
/// but its content never selects. The appearance draws through the page's resources, so the
|
||||
/// rewritten page still lists that font and B's program stays.
|
||||
#[test]
|
||||
fn a_type3_font_only_an_appearance_without_resources_selects_stays_on_the_page() {
|
||||
let mut doc = pdf(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 300] /Contents 4 0 R /Resources 5 0 R /Annots [8 0 R] >>".to_vec(),
|
||||
stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET"),
|
||||
b"<< /Font << /T3 6 0 R /T4 7 0 R >> >>".to_vec(),
|
||||
type3_abc(10),
|
||||
type3_abc(13),
|
||||
b"<< /Type /Annot /Subtype /FreeText /Rect [150 200 250 220] /P 3 0 R /DA (/T4 10 Tf 0 g) /AP << /N 9 0 R >> >>".to_vec(),
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20]", b"BT /T4 10 Tf 2 5 Td (B) Tj ET"),
|
||||
stream("", b"% REDACTED_GLYPH_A"),
|
||||
stream("", b"% UNUSED_GLYPH_B"),
|
||||
stream("", b"% PAGE_GLYPH_C"),
|
||||
stream("", b"% UNUSED_T4_GLYPH_A"),
|
||||
stream("", b"% APPEARANCE_GLYPH_B"),
|
||||
stream("", b"% UNUSED_T4_GLYPH_C"),
|
||||
]);
|
||||
mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], "");
|
||||
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1);
|
||||
assert!(!full_save_holds(&doc, b"REDACTED_GLYPH_A"), "the redacted glyph program survived");
|
||||
assert!(full_save_holds(&doc, b"APPEARANCE_GLYPH_B"), "the glyph the appearance shows was unlinked");
|
||||
|
||||
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
let saved = Document::open(Arc::new(bytes)).unwrap();
|
||||
let page = pdfcraft_model::pages(&saved).swap_remove(0);
|
||||
let res = saved.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
|
||||
let fonts = saved.resolve(res.get(b"Font").unwrap()).as_dict().unwrap().clone();
|
||||
assert!(fonts.contains(b"T4"), "the page no longer lists the font the appearance selects");
|
||||
}
|
||||
|
||||
/// The cell of a tiling pattern the page fills with shows glyph B: its program is kept.
|
||||
#[test]
|
||||
fn a_type3_glyph_a_tiling_pattern_shows_is_kept() {
|
||||
|
||||
+52
-14
@@ -357,22 +357,50 @@ fn scan_soft_mask(ctx: &mut ScanContext<'_>, res: &Dict, holder_ref: ObjRef, nam
|
||||
/// through the page's.
|
||||
fn scan_appearances(ctx: &mut ScanContext<'_>, page: &Dict, page_ref: ObjRef, page_res: &Dict) -> Result<(), CosError> {
|
||||
for annot in crate::annots_of(ctx.doc, page) {
|
||||
let Some(ap) = ctx.doc.resolve(&annot).as_dict().and_then(|a| a.get(b"AP").cloned()) else { continue };
|
||||
let Some(ap) = ctx.doc.resolve(&ap).as_dict().cloned() else { continue };
|
||||
for (_, entry) in ap.iter() {
|
||||
// Each entry is one stream, or a dictionary of streams by appearance state.
|
||||
let streams: Vec<ObjRef> = match &*ctx.doc.resolve(entry) {
|
||||
Object::Dict(states) => states.iter().filter_map(|(_, s)| s.as_ref()).collect(),
|
||||
_ => entry.as_ref().into_iter().collect(),
|
||||
};
|
||||
for r in streams {
|
||||
scan_stream_object(ctx, page_res, page_ref, r, None, 0)?;
|
||||
}
|
||||
for r in appearance_streams(ctx.doc, &annot) {
|
||||
scan_stream_object(ctx, page_res, page_ref, r, None, 0)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The streams an annotation can show: its normal, rollover and down appearances (ISO 32000-2
|
||||
/// §12.5.5), each one stream or a dictionary of streams by appearance state. Readers draw no
|
||||
/// other `/AP` entry, so one can't keep a glyph the page no longer shows.
|
||||
fn appearance_streams(doc: &Document, annot: &Object) -> Vec<ObjRef> {
|
||||
let Some(ap) = doc.resolve(annot).as_dict().and_then(|a| a.get(b"AP").cloned()) else { return Vec::new() };
|
||||
let Some(ap) = doc.resolve(&ap).as_dict().cloned() else { return Vec::new() };
|
||||
let mut streams = Vec::new();
|
||||
for key in [b"N", b"R", b"D"] {
|
||||
let Some(entry) = ap.get(key) else { continue };
|
||||
match &*doc.resolve(entry) {
|
||||
Object::Dict(states) => streams.extend(states.iter().filter_map(|(_, s)| s.as_ref())),
|
||||
_ => streams.extend(entry.as_ref()),
|
||||
}
|
||||
}
|
||||
streams
|
||||
}
|
||||
|
||||
/// The decoded appearance streams of a page's annotations that have no resources of their own:
|
||||
/// they draw through the page's, as [`scan_stream_object`] reads them.
|
||||
fn resourceless_appearances(doc: &Document, page: &Dict) -> Result<Vec<Vec<u8>>, CosError> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for annot in crate::annots_of(doc, page) {
|
||||
for r in appearance_streams(doc, &annot) {
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
if let Object::Stream(s) = &*doc.get(r)
|
||||
&& s.dict.get(b"Resources").is_none_or(|ro| doc.resolve(ro).as_dict().is_none())
|
||||
{
|
||||
out.push(s.decoded()?);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Scan a content stream object (a form, a pattern cell, a soft mask group or an appearance), once
|
||||
/// per (stream, font, drawing holder). One with resources of its own draws through them; one
|
||||
/// without draws through its caller's.
|
||||
@@ -406,8 +434,9 @@ fn gs_font_object(doc: &Document, state_obj: &Object) -> Option<Object> {
|
||||
}
|
||||
|
||||
/// Prune Type 3 fonts from a rewritten page or form's `/Resources` dictionary if the rewritten
|
||||
/// content no longer draws any text using that font name.
|
||||
pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content: &[Vec<u8>]) -> Result<(), CosError> {
|
||||
/// content no longer draws any text using that font name. `page` is the page's dictionary when
|
||||
/// `res` are a page's resources.
|
||||
pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content: &[Vec<u8>], page: Option<&Dict>) -> Result<(), CosError> {
|
||||
let mut fonts = res_dict(doc, res, b"Font");
|
||||
if fonts.is_empty() {
|
||||
return Ok(());
|
||||
@@ -415,7 +444,16 @@ pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content
|
||||
|
||||
// The operators of forms that draw through these resources count too: dropping a font a child
|
||||
// form still shows would leave its text without one.
|
||||
let ops = crate::interp::resource_ops(doc, res, content)?;
|
||||
let mut ops = crate::interp::resource_ops(doc, res, content)?;
|
||||
// So do those of a page's annotation appearances without resources of their own. They are
|
||||
// read only when there is a Type 3 font to keep, so other files are not refused over them.
|
||||
if let Some(page) = page
|
||||
&& fonts.iter().any(|(_, f)| doc.resolve(f).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Type3")))
|
||||
{
|
||||
for data in resourceless_appearances(doc, page)? {
|
||||
ops.extend(crate::interp::resource_ops(doc, res, std::slice::from_ref(&data))?);
|
||||
}
|
||||
}
|
||||
|
||||
let mut drawn_font_names = HashSet::new();
|
||||
for op in &ops {
|
||||
|
||||
Reference in New Issue
Block a user