nigig-org/crates/apps/pdf/pdf-document/tests/flatten_roundtrip.rs
andodeki e0d86274d8
Some checks failed
email.yml / feat(pdf): flatten annotations and form fields into page content (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
feat(pdf): flatten annotations and form fields into page content
Phase 5's `flatten_test.dart`. An annotation draws from its /AP /N stream,
which lives beside the page rather than in it; flattening moves that
appearance into the page's own content and removes the annotation, so what
is drawn is part of the page and cannot be turned off, edited or extracted
as a field. That is what "finalise this form" and "make these comments
permanent" mean, and it is irreversible by design.

The placement transform is the whole problem, and it is §12.5.5: transform
the /BBox by /Matrix, take the bounding box of the *result*, then fit that
onto /Rect. Skip a step and the stamp lands at the origin, or in the right
place at the wrong size, and the page still renders. A rotated appearance
is the case that exposes it — rotation swaps the transformed box's width
and height, so fitting the untransformed box squashes it.

What is refused matters as much as what is done:

- **No appearance stream**: left in place and reported. Dropping it loses
  it; inventing an appearance draws something the producer never specified.
- **Hidden or /NoView**: not drawn on screen, so burning it in would *add*
  ink the user never saw.
- **A /Popup**: the pop-up window of another annotation, never drawn on the
  page itself.

Appearances are painted as XObjects rather than having their operators
spliced in. Splicing needs the stream's resources merged into the page's
with every name collision renamed, and it loses the /BBox clip an XObject
applies for free.

`pdf-document` cannot depend on `pdf-graphics` — the crate boundary is
cos -> document -> graphics and inverting it to reuse `write_ops` would be
a far worse trade than emitting the four operators (`q`, `cm`, `Do`, `Q`)
directly. The number formatting follows the same shortest-exact rule as
`content_edit::write_real`, and for the same reason.

Verified by mutation, five defects, each confirmed red:

  placement matrix ignored          1 fail
  /BBox not transformed first       2 fail
  hidden check removed              1 fail
  annotation kept after flattening  7 fail
  existing page content dropped     1 fail

**Externally verified, and it found a real gap.** Flattening the sample's
seven annotations passed `qpdf --check` and kept every text run — but
poppler still reported `Form: AcroForm` on a document with no fields left,
because the catalogue entry survived. A viewer may still offer to fill in
a form that no longer exists. `remove_acroform_if_empty` drops it, but
only when *no* widget survives anywhere: flattening one page of a
three-page form must not strip the fields still live on the others.

  before:  Form: AcroForm    after:  Form: none

`flatten_document` is the whole-document entry point — every page, then
the form entry — and re-parses between pages because each flatten appends
a revision the next must read.

22 round-trip tests through the saved file, including that flattening
twice is idempotent (a Do count that grows on every save is how a
"flatten" button pressed twice doubles every stamp), that existing page
resources survive, and that a /AP /N state dictionary resolves through /AS.

Engine suite 1075 -> 1108.

Remaining in Phase 5: object compaction, redaction, and outline, page
label and struct-tree editing.
2026-08-17 12:03:59 +00:00

794 lines
27 KiB
Rust

//! Flattening, read back from the saved file.
//!
//! The unit tests in `flatten.rs` check the placement arithmetic. These
//! check the **document**: that the appearance really is in the page's
//! content, that the annotation really is gone, and that a reader sees
//! both.
//!
//! Flattening is irreversible, so getting it wrong destroys the user's
//! data in a way no later step can recover. The failure modes are all
//! quiet: the annotation is removed but nothing is drawn (content lost),
//! or it is drawn in the wrong place (content moved), or it is drawn and
//! also still an annotation (drawn twice). None of them raises an error,
//! so each has an assertion here.
use nigig_pdf_cos::object::{PdfDict, PdfObj};
use nigig_pdf_document::flatten::{flatten_page, FlattenScope, SkipReason};
use nigig_pdf_document::PdfDocument;
/// A one-page document with annotations, built so each is identifiable.
///
/// `annots` supplies the extra dictionary entries for each annotation, so
/// a test can vary flags, rect and appearance without a new fixture.
fn document_with_annotations(annots: &[(&str, &str)]) -> Vec<u8> {
document_with_catalog_extra(annots, "")
}
/// As above, with extra entries spliced into the catalogue dictionary.
///
/// The extras go in *before* the xref offsets are computed. Patching the
/// bytes afterwards shifts every object and leaves the table pointing at
/// the wrong places — which is exactly what the first version of the
/// /AcroForm tests did, and the file then failed to parse.
fn document_with_catalog_extra(annots: &[(&str, &str)], catalog_extra: &str) -> Vec<u8> {
let mut out = String::from("%PDF-1.7\n");
let mut offsets: Vec<usize> = Vec::new();
let push = |out: &mut String, offsets: &mut Vec<usize>, body: &str| {
offsets.push(out.len());
out.push_str(body);
};
// 1 catalog, 2 pages, 3 page, 4 content, then annotation/appearance
// pairs from 5.
let annot_refs: Vec<String> = (0..annots.len())
.map(|i| format!("{} 0 R", 5 + i * 2))
.collect();
push(
&mut out,
&mut offsets,
&format!("1 0 obj\n<< /Type /Catalog /Pages 2 0 R {catalog_extra}>>\nendobj\n"),
);
push(
&mut out,
&mut offsets,
"2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n",
);
let page = format!(
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \
/Contents 4 0 R /Annots [{}] >>\nendobj\n",
annot_refs.join(" ")
);
push(&mut out, &mut offsets, &page);
let content = "BT (page body) Tj ET";
push(
&mut out,
&mut offsets,
&format!(
"4 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\n",
content.len()
),
);
for (i, (annot_entries, appearance_content)) in annots.iter().enumerate() {
let annot_num = 5 + i * 2;
let ap_num = annot_num + 1;
let annot = if appearance_content.is_empty() {
format!("{annot_num} 0 obj\n<< /Type /Annot {annot_entries} >>\nendobj\n")
} else {
format!(
"{annot_num} 0 obj\n<< /Type /Annot {annot_entries} \
/AP << /N {ap_num} 0 R >> >>\nendobj\n"
)
};
push(&mut out, &mut offsets, &annot);
let ap = format!(
"{ap_num} 0 obj\n<< /Type /XObject /Subtype /Form \
/BBox [0 0 10 10] /Length {} >>\nstream\n{appearance_content}\nendstream\nendobj\n",
appearance_content.len()
);
push(&mut out, &mut offsets, &ap);
}
let startxref = out.len();
let total = 5 + annots.len() * 2;
out.push_str(&format!("xref\n0 {total}\n0000000000 65535 f \n"));
for off in &offsets {
out.push_str(&format!("{off:010} 00000 n \n"));
}
out.push_str(&format!(
"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n"
));
out.into_bytes()
}
fn parse(bytes: &[u8]) -> PdfDocument<'_> {
PdfDocument::parse(bytes).expect("parses")
}
fn page_content(bytes: &[u8]) -> String {
let mut doc = parse(bytes);
let page = doc.page(0).expect("page 0");
String::from_utf8_lossy(&page.content_data).to_string()
}
fn annotation_count(bytes: &[u8]) -> usize {
let mut doc = parse(bytes);
doc.page_annotations(0).map(|a| a.len()).unwrap_or(0)
}
#[test]
fn the_fixture_reads_back_with_its_annotation() {
let bytes = document_with_annotations(&[(
"/Subtype /Square /Rect [100 100 200 200]",
"1 0 0 rg 0 0 10 10 re f",
)]);
assert_eq!(annotation_count(&bytes), 1);
assert!(page_content(&bytes).contains("page body"));
}
/// The core round trip: the appearance lands in the page, the annotation
/// goes away, and the original content survives.
#[test]
fn flattening_moves_the_appearance_into_the_page_and_removes_the_annotation() {
let bytes = document_with_annotations(&[(
"/Subtype /Square /Rect [100 100 200 200]",
"1 0 0 rg 0 0 10 10 re f",
)]);
let mut doc = parse(&bytes);
let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens");
assert_eq!(report.flattened.len(), 1, "one annotation was flattened");
assert!(report.skipped.is_empty(), "nothing should be skipped");
assert_eq!(
annotation_count(&saved),
0,
"the annotation must be gone from /Annots"
);
let content = page_content(&saved);
assert!(
content.contains("page body"),
"the original page content was lost: {content}"
);
assert!(
content.contains(" Do"),
"the appearance is not painted into the page: {content}"
);
assert!(
content.contains("cm"),
"no placement matrix was written: {content}"
);
}
/// The appearance must be reachable as a page resource.
///
/// A `Do` naming an XObject the page does not declare draws *nothing*, and
/// nothing errors — the annotation would simply vanish. This is the exact
/// failure ADR 0017 is about, in a new place.
#[test]
fn the_flattened_appearance_is_declared_in_the_page_resources() {
let bytes = document_with_annotations(&[(
"/Subtype /Square /Rect [100 100 200 200]",
"1 0 0 rg 0 0 10 10 re f",
)]);
let mut doc = parse(&bytes);
let (saved, _) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens");
let mut saved_doc = parse(&saved);
let page = saved_doc.page(0).expect("page 0");
assert!(
!page.xobjects.is_empty(),
"the page declares no XObject, so the painted appearance draws nothing"
);
// The name in the content stream must be one the page declares.
let content = String::from_utf8_lossy(&page.content_data).to_string();
let declared: Vec<&String> = page.xobjects.keys().collect();
assert!(
declared.iter().any(|name| content.contains(name.as_str())),
"the content paints a name the page does not declare: \
content {content:?}, declared {declared:?}"
);
}
/// The placement matrix must put the appearance at the annotation's `/Rect`.
///
/// A stamp drawn at the origin instead of its rectangle is the commonest
/// flatten bug, and the page still renders.
#[test]
fn the_appearance_is_placed_at_the_annotation_rectangle() {
let bytes = document_with_annotations(&[(
"/Subtype /Square /Rect [100 200 300 250]",
"1 0 0 rg 0 0 10 10 re f",
)]);
let mut doc = parse(&bytes);
let (saved, _) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens");
let content = page_content(&saved);
// /BBox is 10x10 onto a 200x50 rect: scale 20 and 5, offset 100,200.
assert!(
content.contains("20 0 0 5 100 200 cm"),
"the placement matrix is wrong; content is: {content}"
);
}
/// An annotation with no appearance is left alone and reported.
///
/// Dropping it would lose it; inventing an appearance would draw something
/// the producer never specified. Neither is acceptable silently.
#[test]
fn an_annotation_without_an_appearance_is_kept_and_reported() {
let bytes = document_with_annotations(&[("/Subtype /Square /Rect [10 10 20 20]", "")]);
let mut doc = parse(&bytes);
let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("no-op");
assert!(report.flattened.is_empty());
assert_eq!(report.skipped.len(), 1);
assert_eq!(report.skipped[0].1, SkipReason::NoAppearance);
assert_eq!(
annotation_count(&saved),
1,
"an unflattenable annotation must survive, not disappear"
);
assert_eq!(saved, bytes, "a no-op flatten must not rewrite the file");
}
/// A hidden annotation is not drawn on screen, so burning it in would add
/// ink the user never saw.
#[test]
fn a_hidden_annotation_is_not_burned_in() {
let bytes = document_with_annotations(&[(
"/Subtype /Square /Rect [10 10 20 20] /F 2",
"1 0 0 rg 0 0 10 10 re f",
)]);
let mut doc = parse(&bytes);
let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("no-op");
assert!(report.flattened.is_empty(), "hidden must not be flattened");
assert_eq!(report.skipped[0].1, SkipReason::NotVisible);
assert_eq!(annotation_count(&saved), 1);
}
/// A degenerate `/Rect` has nowhere to put the appearance.
#[test]
fn a_zero_area_rectangle_is_skipped() {
let bytes = document_with_annotations(&[(
"/Subtype /Square /Rect [10 10 10 20]",
"1 0 0 rg 0 0 10 10 re f",
)]);
let mut doc = parse(&bytes);
let (_, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("no-op");
assert_eq!(report.skipped[0].1, SkipReason::DegenerateRect);
}
/// Scope must select: finalising a form should not destroy the reviewer's
/// comments.
#[test]
fn widgets_only_leaves_other_annotations_editable() {
let bytes = document_with_annotations(&[
(
"/Subtype /Widget /Rect [10 10 100 30] /FT /Tx",
"0 g BT (field) Tj ET",
),
(
"/Subtype /Square /Rect [200 200 300 300]",
"1 0 0 rg 0 0 10 10 re f",
),
]);
let mut doc = parse(&bytes);
assert_eq!(annotation_count(&bytes), 2);
let (saved, report) =
flatten_page(&mut doc, &bytes, 0, FlattenScope::WidgetsOnly).expect("flattens");
assert_eq!(report.flattened.len(), 1, "only the widget is flattened");
assert_eq!(
annotation_count(&saved),
1,
"the square annotation must survive"
);
let content = page_content(&saved);
assert!(content.contains(" Do"), "the widget was painted");
}
#[test]
fn annotations_only_leaves_the_form_fillable() {
let bytes = document_with_annotations(&[
(
"/Subtype /Widget /Rect [10 10 100 30] /FT /Tx",
"0 g BT (field) Tj ET",
),
(
"/Subtype /Square /Rect [200 200 300 300]",
"1 0 0 rg 0 0 10 10 re f",
),
]);
let mut doc = parse(&bytes);
let (saved, report) =
flatten_page(&mut doc, &bytes, 0, FlattenScope::AnnotationsOnly).expect("flattens");
assert_eq!(report.flattened.len(), 1);
assert_eq!(annotation_count(&saved), 1, "the widget must survive");
}
/// Several annotations must all be painted, each in its own place.
#[test]
fn every_annotation_is_flattened_and_isolated() {
let bytes = document_with_annotations(&[
(
"/Subtype /Square /Rect [0 0 10 10]",
"1 0 0 rg 0 0 10 10 re f",
),
(
"/Subtype /Square /Rect [100 100 120 120]",
"0 1 0 rg 0 0 10 10 re f",
),
(
"/Subtype /Square /Rect [200 200 240 240]",
"0 0 1 rg 0 0 10 10 re f",
),
]);
let mut doc = parse(&bytes);
let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens");
assert_eq!(report.flattened.len(), 3);
assert_eq!(annotation_count(&saved), 0);
let content = page_content(&saved);
assert_eq!(
content.matches(" Do").count(),
3,
"each appearance must be painted once: {content}"
);
// Each must be isolated, or one appearance's colour leaks into the
// next. Three appearances plus the outer wrapper is four q/Q pairs.
assert!(
content.matches('q').count() >= 4 && content.matches('Q').count() >= 4,
"appearances are not isolated in q/Q: {content}"
);
}
/// A page with no annotations must be returned untouched.
#[test]
fn a_page_with_no_annotations_is_unchanged() {
let bytes = document_with_annotations(&[]);
let mut doc = parse(&bytes);
let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("no-op");
assert!(!report.did_anything());
assert_eq!(saved, bytes);
}
/// An incremental save appends: the original revision stays byte-identical.
#[test]
fn the_original_revision_is_left_untouched() {
let bytes = document_with_annotations(&[(
"/Subtype /Square /Rect [100 100 200 200]",
"1 0 0 rg 0 0 10 10 re f",
)]);
let mut doc = parse(&bytes);
let (saved, _) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens");
assert!(saved.len() > bytes.len());
assert_eq!(
&saved[..bytes.len()],
&bytes[..],
"flattening rewrote the original revision"
);
}
/// Flattening twice must not paint the appearance twice.
///
/// The second pass has no annotations left to flatten, so it must be a
/// no-op. A `Do` count that grows on each save is how a "flatten" button
/// pressed twice doubles every stamp's opacity.
#[test]
fn flattening_twice_is_idempotent() {
let bytes = document_with_annotations(&[(
"/Subtype /Square /Rect [100 100 200 200]",
"1 0 0 rg 0 0 10 10 re f",
)]);
let mut doc = parse(&bytes);
let (once, _) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens");
let mut doc2 = parse(&once);
let (twice, report) =
flatten_page(&mut doc2, &once, 0, FlattenScope::All).expect("second pass");
assert!(!report.did_anything(), "the second flatten found nothing");
assert_eq!(twice, once, "a second flatten must not change the file");
assert_eq!(
page_content(&twice).matches(" Do").count(),
1,
"the appearance was painted twice"
);
}
/// A checkbox's `/AP /N` is a dictionary of states; `/AS` picks one.
#[test]
fn an_appearance_state_dictionary_is_resolved_through_as() {
// Built by hand: the fixture helper only writes a plain /AP /N stream.
let mut out = String::from("%PDF-1.7\n");
let mut offsets = Vec::new();
let push = |out: &mut String, offsets: &mut Vec<usize>, s: &str| {
offsets.push(out.len());
out.push_str(s);
};
push(
&mut out,
&mut offsets,
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
);
push(
&mut out,
&mut offsets,
"2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n",
);
push(
&mut out,
&mut offsets,
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \
/Contents 4 0 R /Annots [5 0 R] >>\nendobj\n",
);
push(
&mut out,
&mut offsets,
"4 0 obj\n<< /Length 4 >>\nstream\nq Q\nendstream\nendobj\n",
);
push(
&mut out,
&mut offsets,
"5 0 obj\n<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \
/AS /On /AP << /N << /On 6 0 R /Off 7 0 R >> >> >>\nendobj\n",
);
for (num, body) in [(6, "0 0 1 rg 0 0 5 5 re f"), (7, "1 1 1 rg 0 0 5 5 re f")] {
push(
&mut out,
&mut offsets,
&format!(
"{num} 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 5 5] \
/Length {} >>\nstream\n{body}\nendstream\nendobj\n",
body.len()
),
);
}
let startxref = out.len();
out.push_str("xref\n0 8\n0000000000 65535 f \n");
for off in &offsets {
out.push_str(&format!("{off:010} 00000 n \n"));
}
out.push_str(&format!(
"trailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n"
));
let bytes = out.into_bytes();
let mut doc = parse(&bytes);
let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens");
assert_eq!(
report.flattened.len(),
1,
"the /On state should have been resolved and painted: {:?}",
report.skipped
);
assert_eq!(annotation_count(&saved), 0);
}
/// Existing page resources must survive: flattening adds an XObject, it
/// does not replace the page's fonts.
#[test]
fn existing_page_resources_are_preserved() {
let mut out = String::from("%PDF-1.7\n");
let mut offsets = Vec::new();
let push = |out: &mut String, offsets: &mut Vec<usize>, s: &str| {
offsets.push(out.len());
out.push_str(s);
};
push(
&mut out,
&mut offsets,
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
);
push(
&mut out,
&mut offsets,
"2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n",
);
push(
&mut out,
&mut offsets,
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \
/Contents 4 0 R /Annots [5 0 R] \
/Resources << /Font << /F1 7 0 R >> >> >>\nendobj\n",
);
push(
&mut out,
&mut offsets,
"4 0 obj\n<< /Length 4 >>\nstream\nq Q\nendstream\nendobj\n",
);
push(
&mut out,
&mut offsets,
"5 0 obj\n<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] \
/AP << /N 6 0 R >> >>\nendobj\n",
);
push(
&mut out,
&mut offsets,
"6 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 5 5] /Length 21 >>\n\
stream\n0 0 1 rg 0 0 5 5 re f\nendstream\nendobj\n",
);
push(
&mut out,
&mut offsets,
"7 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n",
);
let startxref = out.len();
out.push_str("xref\n0 8\n0000000000 65535 f \n");
for off in &offsets {
out.push_str(&format!("{off:010} 00000 n \n"));
}
out.push_str(&format!(
"trailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n"
));
let bytes = out.into_bytes();
let mut doc = parse(&bytes);
let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens");
assert_eq!(report.flattened.len(), 1);
let mut saved_doc = parse(&saved);
let page = saved_doc.page(0).expect("page");
assert!(
page.fonts.contains_key("F1"),
"flattening dropped the page's existing font resource: {:?}",
page.fonts.keys().collect::<Vec<_>>()
);
assert!(!page.xobjects.is_empty(), "and it added the appearance");
}
/// Flattening a page that does not exist is refused.
#[test]
fn flattening_a_missing_page_is_refused() {
let bytes = document_with_annotations(&[]);
let mut doc = parse(&bytes);
assert!(flatten_page(&mut doc, &bytes, 9, FlattenScope::All).is_err());
}
/// The generated resource name must not collide with one the page already
/// declares, or flattening silently replaces an existing image.
#[test]
fn the_generated_resource_name_does_not_collide() {
let bytes = document_with_annotations(&[(
"/Subtype /Square /Rect [100 100 200 200]",
"1 0 0 rg 0 0 10 10 re f",
)]);
let mut doc = parse(&bytes);
let (saved, _) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens");
let mut saved_doc = parse(&saved);
let page = saved_doc.page(0).expect("page");
// The name is generated, so assert the property rather than the string:
// every declared XObject resolves, and the content names one of them.
for (name, resource) in &page.xobjects {
assert!(
!name.is_empty(),
"an empty resource name cannot be referenced"
);
assert_eq!(
resource.subtype, "Form",
"a flattened appearance must be a form XObject"
);
}
}
/// A `PdfDict` round trip through the writer, as a sanity check that the
/// appearance object itself is reused rather than copied.
#[test]
fn a_shared_appearance_is_referenced_not_duplicated() {
let bytes = document_with_annotations(&[
(
"/Subtype /Square /Rect [0 0 10 10]",
"1 0 0 rg 0 0 10 10 re f",
),
(
"/Subtype /Square /Rect [50 50 60 60]",
"1 0 0 rg 0 0 10 10 re f",
),
]);
let mut doc = parse(&bytes);
let before = doc.max_object_number();
let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens");
assert_eq!(report.flattened.len(), 2);
let saved_doc = parse(&saved);
// Only the new content stream and the rewritten page are added; the
// two appearance streams already existed as indirect objects and are
// referenced in place.
assert!(
saved_doc.max_object_number() <= before + 2,
"flattening duplicated appearance objects: {} -> {}",
before,
saved_doc.max_object_number()
);
}
/// The page dictionary must not keep a dangling `/Annots` entry.
#[test]
fn a_fully_flattened_page_has_no_annots_key() {
let bytes = document_with_annotations(&[(
"/Subtype /Square /Rect [100 100 200 200]",
"1 0 0 rg 0 0 10 10 re f",
)]);
let mut doc = parse(&bytes);
let (saved, _) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens");
let mut saved_doc = parse(&saved);
let page_ref = saved_doc.page_object_ref(0).expect("page ref");
let page = saved_doc.resolve_ref(page_ref).expect("page object");
let dict: PdfDict = page.as_dict().cloned().expect("dict");
match dict.get("Annots") {
None => {}
Some(PdfObj::Array(a)) => assert!(
a.is_empty(),
"/Annots still lists {} annotations after a full flatten",
a.len()
),
Some(other) => panic!("/Annots is a {other:?}"),
}
}
// ------------------------------------------------- whole-document flatten
use nigig_pdf_document::flatten::flatten_document;
/// Flattening every field must also remove `/AcroForm`.
///
/// Leaving it behind means a reader still reports the document as a form:
/// poppler's `pdfinfo` printed `Form: AcroForm` on a file with no fields
/// left, and a viewer may still offer to fill it in. Found by running the
/// flattened sample through poppler rather than by reading the code.
#[test]
fn flattening_every_field_removes_the_acroform_entry() {
let with_form = document_with_catalog_extra(
&[(
"/Subtype /Widget /Rect [10 10 100 30] /FT /Tx",
"0 g BT (field) Tj ET",
)],
"/AcroForm << /Fields [5 0 R] >> ",
);
let mut doc = parse(&with_form);
assert!(
doc.acroform().ok().flatten().is_some(),
"the fixture must start with a form"
);
drop(doc);
let (saved, report) = flatten_document(&with_form, FlattenScope::All).expect("flattens");
assert_eq!(report.flattened.len(), 1);
assert!(
report.acroform_removed,
"the form entry must be removed once no field survives"
);
let mut saved_doc = parse(&saved);
let form = saved_doc.acroform().ok().flatten();
assert!(
form.is_none() || form.is_some_and(|f| f.field_count() == 0),
"the document still declares a form after every field was flattened"
);
}
/// A form with a field still live on another page must keep `/AcroForm`.
///
/// Removing it because *this* page is clean would break the fields that
/// remain, which is a far worse outcome than a stale entry.
#[test]
fn a_partly_flattened_form_keeps_its_acroform() {
let with_form = document_with_catalog_extra(
&[(
"/Subtype /Widget /Rect [10 10 100 30] /FT /Tx",
"0 g BT (field) Tj ET",
)],
"/AcroForm << /Fields [5 0 R] >> ",
);
// Flatten *annotations only*, so the widget survives.
let (saved, report) =
flatten_document(&with_form, FlattenScope::AnnotationsOnly).expect("flattens");
assert!(
!report.acroform_removed,
"a surviving widget must keep the form entry"
);
assert_eq!(annotation_count(&saved), 1);
}
/// The whole-document helper must flatten every page, not only the first.
#[test]
fn flatten_document_covers_every_page() {
// Two pages, each with one annotation.
let mut out = String::from("%PDF-1.7\n");
let mut offsets = Vec::new();
let push = |out: &mut String, offsets: &mut Vec<usize>, s: &str| {
offsets.push(out.len());
out.push_str(s);
};
push(
&mut out,
&mut offsets,
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
);
push(
&mut out,
&mut offsets,
"2 0 obj\n<< /Type /Pages /Count 2 /Kids [3 0 R 4 0 R] >>\nendobj\n",
);
push(
&mut out,
&mut offsets,
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \
/Contents 5 0 R /Annots [6 0 R] >>\nendobj\n",
);
push(
&mut out,
&mut offsets,
"4 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \
/Contents 5 0 R /Annots [8 0 R] >>\nendobj\n",
);
push(
&mut out,
&mut offsets,
"5 0 obj\n<< /Length 4 >>\nstream\nq Q\nendstream\nendobj\n",
);
for (annot, ap) in [(6, 7), (8, 9)] {
push(
&mut out,
&mut offsets,
&format!(
"{annot} 0 obj\n<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] \
/AP << /N {ap} 0 R >> >>\nendobj\n"
),
);
push(
&mut out,
&mut offsets,
&format!(
"{ap} 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 5 5] \
/Length 21 >>\nstream\n0 0 1 rg 0 0 5 5 re f\nendstream\nendobj\n"
),
);
}
let startxref = out.len();
out.push_str("xref\n0 10\n0000000000 65535 f \n");
for off in &offsets {
out.push_str(&format!("{off:010} 00000 n \n"));
}
out.push_str(&format!(
"trailer\n<< /Size 10 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n"
));
let bytes = out.into_bytes();
let doc = parse(&bytes);
assert_eq!(doc.page_count(), 2, "the fixture must have two pages");
drop(doc);
let (saved, report) = flatten_document(&bytes, FlattenScope::All).expect("flattens");
assert_eq!(
report.flattened.len(),
2,
"both pages' annotations must be flattened"
);
let mut saved_doc = parse(&saved);
for page in 0..2 {
assert_eq!(
saved_doc
.page_annotations(page)
.map(|a| a.len())
.unwrap_or(0),
0,
"page {page} still has annotations"
);
}
}