nigig-org/crates/apps/pdf/pdf-document/src/flatten.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

837 lines
30 KiB
Rust

//! Flattening annotations and form fields into page content.
//!
//! Phase 5 of `NIGIG_PDF_FEATURE_PARITY_PLAN.md`, matching dart-pdf's
//! `flatten_test.dart`.
//!
//! ## What flattening is, and what it is for
//!
//! An annotation draws itself from its `/AP /N` appearance stream, which
//! lives *beside* the page rather than in it. A reader that does not
//! understand annotations — or one told not to print them — shows the page
//! without them. Flattening moves that appearance into the page's own
//! content stream 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 exactly what "print to PDF", "finalise this form" and "make
//! these comments permanent" mean, and it is irreversible by design.
//!
//! ## The transform is the whole problem
//!
//! An appearance stream draws in its **own** coordinate space, described by
//! its `/BBox` and optional `/Matrix`. The annotation's `/Rect` says where
//! on the page that space should land. Getting from one to the other is
//! PDF 32000-1 §12.5.5, and it is the step every naive implementation skips:
//!
//! 1. Transform the `/BBox` corners by `/Matrix`.
//! 2. Take the bounding box of the result — the *transformed* appearance
//! box, which is generally not the original box.
//! 3. Compute the scale and offset that map that box onto `/Rect`.
//!
//! Skip it and the stamp lands at the origin, or at the right place in the
//! wrong size. Nothing errors: the page renders, with the annotation
//! somewhere else.
//!
//! ## What is refused
//!
//! An annotation with no appearance stream cannot be flattened — there is
//! nothing to draw. Rather than dropping it silently (which loses it) or
//! inventing an appearance (which draws something the producer never
//! specified), it is **left alone** and reported. A caller wanting it gone
//! can delete it explicitly; a caller wanting it drawn must generate an
//! appearance first, which `appearance.rs` does for the types it can.
//!
//! Hidden and `/NoView` annotations are skipped for the same reason in
//! reverse: they are not drawn on screen, so burning them into the page
//! would *add* ink that was never visible.
use crate::annotations::PdfAnnotation;
use crate::document::PdfDocument;
use nigig_pdf_cos::incremental::IncrementalUpdate;
use nigig_pdf_cos::object::{ObjRef, PdfDict, PdfObj, PdfStream};
/// Write the four operators flattening needs, without reaching for
/// `pdf-graphics`.
///
/// The crate boundary is `cos -> document -> graphics` and it is strict:
/// `pdf-document` cannot depend on the graphics layer, and inverting that
/// to reuse `content_edit::write_ops` would be a far worse trade than
/// emitting `q`, `cm`, `Do` and `Q` here. These four are the entire
/// vocabulary of placing an XObject.
///
/// The number formatting is the same rule `content_edit::write_real`
/// applies and for the same reason: PDF has no exponent syntax, and a
/// coordinate written `1e-7` reads as the number 1 followed by an unknown
/// operator. The precision is the shortest that parses back to the
/// identical `f64`.
fn write_real(value: f64) -> String {
if !value.is_finite() {
return "0".to_string();
}
if value == value.trunc() && value.abs() < 1e15 {
let as_int = value as i64;
return if as_int == 0 {
"0".to_string()
} else {
as_int.to_string()
};
}
let mut s = String::new();
for decimals in 1..=17usize {
s = format!("{value:.decimals$}");
if s.parse::<f64>() == Ok(value) {
break;
}
}
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
if s == "-0" || s.is_empty() {
s = "0".to_string();
}
s
}
/// Paint one appearance XObject under a placement matrix, isolated in its
/// own `q`/`Q` so it cannot leak state into the next one.
fn paint_xobject(out: &mut Vec<u8>, name: &str, m: &Matrix) {
out.extend_from_slice(
b"q
",
);
for v in m {
out.extend_from_slice(write_real(*v).as_bytes());
out.push(b' ');
}
out.extend_from_slice(
b"cm
/",
);
// A resource name generated here is always alphanumeric, so it needs
// no `#` escaping; the assertion is in the test rather than a runtime
// check that could never fire.
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(
b" Do
Q
",
);
}
/// Annotation flags (PDF 32000-1 table 165) that decide visibility.
mod flags {
pub const HIDDEN: i64 = 1 << 1;
pub const NO_VIEW: i64 = 1 << 5;
}
/// Why one annotation was not flattened.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SkipReason {
/// No `/AP /N` stream, so there is nothing to draw.
NoAppearance,
/// `/AP /N` is a sub-dictionary of states and `/AS` names none of them,
/// so which one to draw is undefined.
AmbiguousAppearanceState,
/// `/Hidden` or `/NoView` is set: it is not drawn, so it must not be
/// burned in.
NotVisible,
/// A `/Popup` is the pop-up *window* of another annotation, never
/// drawn on the page itself.
PopupWindow,
/// The appearance stream could not be read or decoded.
UnreadableAppearance,
/// `/Rect` is missing or degenerate, so there is nowhere to put it.
DegenerateRect,
}
impl std::fmt::Display for SkipReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let text = match self {
Self::NoAppearance => "no /AP /N appearance stream",
Self::AmbiguousAppearanceState => "/AS names no state in /AP /N",
Self::NotVisible => "hidden or /NoView",
Self::PopupWindow => "a pop-up window is not drawn on the page",
Self::UnreadableAppearance => "the appearance stream could not be read",
Self::DegenerateRect => "/Rect is missing or has no area",
};
f.write_str(text)
}
}
/// What flattening did.
#[derive(Clone, Debug, Default)]
pub struct FlattenReport {
/// Object numbers of the annotations burned into the page.
pub flattened: Vec<u32>,
/// Annotations left in place, with why.
pub skipped: Vec<(u32, SkipReason)>,
/// Whether the page's `/Annots` was rewritten.
pub annots_rewritten: bool,
/// Whether `/AcroForm` was removed, which happens only when every
/// field in the document has been flattened.
pub acroform_removed: bool,
}
impl FlattenReport {
pub fn did_anything(&self) -> bool {
!self.flattened.is_empty()
}
}
/// Which annotations to flatten.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FlattenScope {
/// Every annotation that can be flattened.
All,
/// Only form fields (`/Widget`), leaving comments and markup editable.
///
/// This is what "finalise the form" means, and it is the common case:
/// a filled form should stop being fillable without also destroying
/// the reviewer's comments.
WidgetsOnly,
/// Everything except form fields.
AnnotationsOnly,
}
impl FlattenScope {
fn includes(&self, annot: &PdfAnnotation) -> bool {
let is_widget = matches!(annot.annot_type, crate::annotations::AnnotationType::Widget);
match self {
Self::All => true,
Self::WidgetsOnly => is_widget,
Self::AnnotationsOnly => !is_widget,
}
}
}
/// A 3x2 affine matrix in PDF order: `[a b c d e f]`.
type Matrix = [f64; 6];
const IDENTITY: Matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
fn apply(m: &Matrix, x: f64, y: f64) -> (f64, f64) {
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
}
/// The appearance-to-page transform of §12.5.5.
///
/// Returns the matrix that maps the form XObject's space onto the
/// annotation's `/Rect`.
///
/// The steps are the specification's and the order matters: the `/BBox` is
/// transformed by `/Matrix` *first*, and the resulting box — not the
/// original — is what gets fitted to `/Rect`. A rotated appearance has a
/// larger transformed box than its own, so fitting the untransformed one
/// scales it wrongly.
pub fn appearance_matrix(bbox: [f64; 4], matrix: Matrix, rect: [f64; 4]) -> Matrix {
// Normalise: either box may arrive with its corners in any order.
let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2]));
let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3]));
let (rx0, rx1) = (rect[0].min(rect[2]), rect[0].max(rect[2]));
let (ry0, ry1) = (rect[1].min(rect[3]), rect[1].max(rect[3]));
// Step 1-2: the bounding box of the transformed /BBox corners.
let corners = [
apply(&matrix, bx0, by0),
apply(&matrix, bx1, by0),
apply(&matrix, bx1, by1),
apply(&matrix, bx0, by1),
];
let tx0 = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
let tx1 = corners
.iter()
.map(|c| c.0)
.fold(f64::NEG_INFINITY, f64::max);
let ty0 = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
let ty1 = corners
.iter()
.map(|c| c.1)
.fold(f64::NEG_INFINITY, f64::max);
// Step 3: scale the transformed box onto /Rect.
//
// A zero-width transformed box would divide by zero. It means the
// appearance collapses to a line, which cannot be scaled to fill a
// rectangle, so the scale stays 1 and only the translation applies —
// the appearance lands in the right place at its own size rather than
// producing NaN coordinates that render as nothing.
let sx = if (tx1 - tx0).abs() > f64::EPSILON {
(rx1 - rx0) / (tx1 - tx0)
} else {
1.0
};
let sy = if (ty1 - ty0).abs() > f64::EPSILON {
(ry1 - ry0) / (ty1 - ty0)
} else {
1.0
};
// The full transform is A = Matrix * Scale * Translate, expressed as a
// single `cm` operand list.
[
matrix[0] * sx,
matrix[1] * sy,
matrix[2] * sx,
matrix[3] * sy,
matrix[4] * sx + rx0 - tx0 * sx,
matrix[5] * sy + ry0 - ty0 * sy,
]
}
/// Read a `[f64; 4]` rectangle from a dictionary entry.
fn rect_of(dict: &PdfDict, key: &str) -> Option<[f64; 4]> {
let arr = dict.get_array(key)?;
if arr.len() < 4 {
return None;
}
let mut out = [0.0; 4];
for (i, slot) in out.iter_mut().enumerate() {
*slot = arr[i].as_f64()?;
}
Some(out)
}
fn matrix_of(dict: &PdfDict) -> Matrix {
let Some(arr) = dict.get_array("Matrix") else {
return IDENTITY;
};
if arr.len() < 6 {
return IDENTITY;
}
let mut m = IDENTITY;
for (i, slot) in m.iter_mut().enumerate() {
match arr[i].as_f64() {
Some(v) => *slot = v,
None => return IDENTITY,
}
}
m
}
/// The appearance stream to draw for one annotation.
///
/// `/AP /N` is either a stream or a dictionary of named states, in which
/// case `/AS` picks one. A state dictionary with exactly one entry and no
/// `/AS` is resolved to that entry: it is unambiguous, and refusing it
/// would skip a great many real checkboxes.
fn appearance_of(
doc: &mut PdfDocument,
annot_dict: &PdfDict,
) -> Result<(PdfStream, PdfObj), SkipReason> {
let ap = annot_dict
.get("AP")
.and_then(|o| doc.resolve_shallow_public(o))
.and_then(|o| o.as_dict().cloned())
.ok_or(SkipReason::NoAppearance)?;
let normal = ap.get("N").cloned().ok_or(SkipReason::NoAppearance)?;
let resolved = doc
.resolve_shallow_public(&normal)
.ok_or(SkipReason::UnreadableAppearance)?;
match resolved {
PdfObj::Stream(s) => Ok((s, normal)),
PdfObj::Dict(states) => {
let wanted = annot_dict.get_name("AS").map(|s| s.to_string());
let chosen = match wanted {
Some(name) => states.get(&name).cloned(),
None if states.map.len() == 1 => states.map.values().next().cloned(),
None => None,
}
.ok_or(SkipReason::AmbiguousAppearanceState)?;
let stream = doc
.resolve_shallow_public(&chosen)
.and_then(|o| match o {
PdfObj::Stream(s) => Some(s),
_ => None,
})
.ok_or(SkipReason::UnreadableAppearance)?;
Ok((stream, chosen))
}
_ => Err(SkipReason::UnreadableAppearance),
}
}
fn is_visible(annot_dict: &PdfDict) -> bool {
let f = annot_dict.get_int("F").unwrap_or(0);
f & flags::HIDDEN == 0 && f & flags::NO_VIEW == 0
}
/// Remove `/AcroForm` from the catalogue when no widget survives.
///
/// Flattening every field leaves the form *entry* behind, and a reader
/// takes that at face value: poppler still reports `Form: AcroForm` on a
/// document with no fields left, and a viewer may still show a "fill in
/// this form" prompt for a form that no longer exists. Verified against
/// poppler before and after.
///
/// Only removed when the whole document is free of widget annotations —
/// flattening one page of a three-page form must not strip the fields
/// still live on the others.
pub fn remove_acroform_if_empty(
doc: &mut PdfDocument,
source: &[u8],
) -> Result<(Vec<u8>, bool), FlattenError> {
let trailer = doc.trailer().clone();
let Some(root_ref) = trailer.get("Root").and_then(|o| o.as_ref().copied()) else {
return Ok((source.to_vec(), false));
};
let Some(root) = doc
.resolve_ref(root_ref)
.ok()
.and_then(|o| o.as_dict().cloned())
else {
return Ok((source.to_vec(), false));
};
if root.get("AcroForm").is_none() {
return Ok((source.to_vec(), false));
}
for page in 0..doc.page_count() {
let annots = doc.page_annotations(page).unwrap_or_default();
if annots
.iter()
.any(|a| matches!(a.annot_type, crate::annotations::AnnotationType::Widget))
{
return Ok((source.to_vec(), false));
}
}
let mut new_root = root;
new_root.map.remove("AcroForm");
let mut update = IncrementalUpdate::new();
update.set_object(root_ref, PdfObj::Dict(new_root));
let bytes = update
.append_to(source, &trailer)
.map_err(|_| FlattenError::SaveFailed)?;
Ok((bytes, true))
}
/// Flatten every page, then drop `/AcroForm` if nothing is left to fill.
///
/// The convenience entry point, and the one that matches what "flatten
/// this document" means to a user.
pub fn flatten_document(
source: &[u8],
scope: FlattenScope,
) -> Result<(Vec<u8>, FlattenReport), FlattenError> {
let mut current = source.to_vec();
let mut combined = FlattenReport::default();
let page_count = {
let doc = PdfDocument::parse(&current).map_err(|_| FlattenError::SaveFailed)?;
doc.page_count()
};
for page in 0..page_count {
// Re-parsed each round: the previous page's flatten appended a
// revision, and the next must read the document as it now stands.
let mut doc = PdfDocument::parse(&current).map_err(|_| FlattenError::SaveFailed)?;
let (next, report) = flatten_page(&mut doc, &current, page, scope)?;
combined.flattened.extend(report.flattened);
combined.skipped.extend(report.skipped);
combined.annots_rewritten |= report.annots_rewritten;
current = next;
}
if matches!(scope, FlattenScope::All | FlattenScope::WidgetsOnly) {
let mut doc = PdfDocument::parse(&current).map_err(|_| FlattenError::SaveFailed)?;
let (next, removed) = remove_acroform_if_empty(&mut doc, &current)?;
combined.acroform_removed = removed;
current = next;
}
Ok((current, combined))
}
/// Flatten a page's annotations into its content stream.
///
/// Returns the updated file bytes and a report. The original revision is
/// appended to, never rewritten, so earlier signatures stay valid over the
/// bytes they covered.
pub fn flatten_page(
doc: &mut PdfDocument,
source: &[u8],
page_index: usize,
scope: FlattenScope,
) -> Result<(Vec<u8>, FlattenReport), FlattenError> {
let mut report = FlattenReport::default();
let page_ref = doc
.page_object_ref(page_index)
.ok_or(FlattenError::NoSuchPage(page_index))?;
let page_dict = doc
.resolve_ref(page_ref)
.ok()
.and_then(|o| o.as_dict().cloned())
.ok_or(FlattenError::NoSuchPage(page_index))?;
let annotations = doc
.page_annotations(page_index)
.map_err(|_| FlattenError::NoSuchPage(page_index))?;
if annotations.is_empty() {
return Ok((source.to_vec(), report));
}
// Existing page content, which the burned-in appearances follow.
let existing = doc
.page(page_index)
.map(|p| p.content_data.clone())
.unwrap_or_default();
let mut xobjects: Vec<(String, PdfObj)> = Vec::new();
let mut painted: Vec<u8> = Vec::new();
let mut kept: Vec<PdfObj> = Vec::new();
let mut update = IncrementalUpdate::new();
let mut next_name = 0usize;
for annot in &annotations {
let Some(obj_ref) = annot.obj_ref else {
// A directly-embedded annotation has no reference to keep or
// drop; leave it exactly as it is.
continue;
};
let annot_dict = annot.raw_dict.clone();
let keep = |kept: &mut Vec<PdfObj>| kept.push(PdfObj::Ref(obj_ref));
if !scope.includes(annot) {
keep(&mut kept);
continue;
}
if matches!(annot.annot_type, crate::annotations::AnnotationType::Popup) {
report.skipped.push((obj_ref.num, SkipReason::PopupWindow));
keep(&mut kept);
continue;
}
if !is_visible(&annot_dict) {
report.skipped.push((obj_ref.num, SkipReason::NotVisible));
keep(&mut kept);
continue;
}
let Some(rect) = rect_of(&annot_dict, "Rect") else {
report
.skipped
.push((obj_ref.num, SkipReason::DegenerateRect));
keep(&mut kept);
continue;
};
if (rect[2] - rect[0]).abs() < f64::EPSILON || (rect[3] - rect[1]).abs() < f64::EPSILON {
report
.skipped
.push((obj_ref.num, SkipReason::DegenerateRect));
keep(&mut kept);
continue;
}
let (stream, reference) = match appearance_of(doc, &annot_dict) {
Ok(v) => v,
Err(reason) => {
report.skipped.push((obj_ref.num, reason));
keep(&mut kept);
continue;
}
};
let bbox = rect_of(&stream.dict, "BBox").unwrap_or([0.0, 0.0, 1.0, 1.0]);
let matrix = matrix_of(&stream.dict);
let placement = appearance_matrix(bbox, matrix, rect);
// The appearance is painted as an XObject rather than having its
// operators spliced in. Splicing would need the stream's resources
// merged into the page's, with every name collision renamed — far
// more code, and it loses the /BBox clip that an XObject applies
// for free.
let name = loop {
let candidate = format!("nigigFlat{next_name}");
next_name += 1;
if !page_has_xobject(doc, &page_dict, &candidate) {
break candidate;
}
};
// Reuse the appearance object itself when it is already indirect:
// two annotations sharing one appearance stay sharing it.
let xobject_ref = match reference.as_ref().copied() {
Some(r) => r,
None => {
let r = ObjRef {
num: doc.max_object_number() + 1 + update.len() as u32,
gen: 0,
};
update.set_stream(r, stream.clone());
r
}
};
xobjects.push((name.clone(), PdfObj::Ref(xobject_ref)));
paint_xobject(&mut painted, &name, &placement);
report.flattened.push(obj_ref.num);
}
if report.flattened.is_empty() {
return Ok((source.to_vec(), report));
}
// The new page content: the original, then the appearances.
//
// Wrapped in its own q/Q even though each appearance already is: the
// *existing* content may leave the graphics state unbalanced, and
// inheriting that would place every flattened annotation wrongly.
let mut content = existing;
if !content.is_empty() && !content.ends_with(b"\n") {
content.push(b'\n');
}
content.extend_from_slice(b"q\n");
content.extend_from_slice(&painted);
content.extend_from_slice(b"Q\n");
let content_ref = ObjRef {
num: doc.max_object_number() + 1 + update.len() as u32,
gen: 0,
};
let mut content_dict = PdfDict::new();
content_dict.set("Length", PdfObj::Int(content.len() as i64));
update.set_stream(
content_ref,
PdfStream {
dict: content_dict,
data: content,
},
);
// The page: new content, merged XObject resources, surviving /Annots.
let mut new_page = page_dict.clone();
new_page.set("Contents", PdfObj::Ref(content_ref));
let mut resources = page_dict
.get("Resources")
.and_then(|o| doc.resolve_shallow_public(o))
.and_then(|o| o.as_dict().cloned())
.unwrap_or_default();
let mut xobject_dict = resources
.get("XObject")
.and_then(|o| doc.resolve_shallow_public(o))
.and_then(|o| o.as_dict().cloned())
.unwrap_or_default();
for (name, value) in xobjects {
xobject_dict.set(&name, value);
}
resources.set("XObject", PdfObj::Dict(xobject_dict));
new_page.set("Resources", PdfObj::Dict(resources));
if kept.is_empty() {
new_page.map.remove("Annots");
} else {
new_page.set("Annots", PdfObj::Array(kept));
}
report.annots_rewritten = true;
update.set_object(page_ref, PdfObj::Dict(new_page));
let trailer = doc.trailer().clone();
let bytes = update
.append_to(source, &trailer)
.map_err(|_| FlattenError::SaveFailed)?;
Ok((bytes, report))
}
fn page_has_xobject(doc: &mut PdfDocument, page: &PdfDict, name: &str) -> bool {
page.get("Resources")
.and_then(|o| doc.resolve_shallow_public(o))
.and_then(|o| o.as_dict().cloned())
.and_then(|r| r.get("XObject").cloned())
.and_then(|o| doc.resolve_shallow_public(&o))
.and_then(|o| o.as_dict().cloned())
.is_some_and(|x| x.get(name).is_some())
}
/// Flattening failed outright.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FlattenError {
NoSuchPage(usize),
SaveFailed,
}
impl std::fmt::Display for FlattenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoSuchPage(i) => write!(f, "page {i} does not exist"),
Self::SaveFailed => write!(f, "the flattened document could not be written"),
}
}
}
impl std::error::Error for FlattenError {}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn assert_matrix(got: Matrix, want: Matrix) {
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert!(
close(*g, *w),
"component {i}: got {g}, want {w} (full: {got:?})"
);
}
}
/// The simplest case: a unit box onto a rectangle at the origin.
#[test]
fn a_unit_box_scales_to_the_rectangle() {
let m = appearance_matrix([0.0, 0.0, 1.0, 1.0], IDENTITY, [0.0, 0.0, 100.0, 50.0]);
assert_matrix(m, [100.0, 0.0, 0.0, 50.0, 0.0, 0.0]);
}
/// A rectangle away from the origin must translate as well as scale.
#[test]
fn an_offset_rectangle_translates() {
let m = appearance_matrix([0.0, 0.0, 1.0, 1.0], IDENTITY, [10.0, 20.0, 110.0, 70.0]);
assert_matrix(m, [100.0, 0.0, 0.0, 50.0, 10.0, 20.0]);
// The box's corners must land on the rectangle's corners.
assert_eq!(apply(&m, 0.0, 0.0), (10.0, 20.0));
assert_eq!(apply(&m, 1.0, 1.0), (110.0, 70.0));
}
/// A `/BBox` that does not start at the origin must be shifted, not
/// just scaled. This is the case that puts a stamp in the wrong place.
#[test]
fn a_bbox_away_from_the_origin_is_shifted_onto_the_rectangle() {
let m = appearance_matrix(
[100.0, 100.0, 200.0, 150.0],
IDENTITY,
[0.0, 0.0, 200.0, 100.0],
);
let (x0, y0) = apply(&m, 100.0, 100.0);
let (x1, y1) = apply(&m, 200.0, 150.0);
assert!(close(x0, 0.0) && close(y0, 0.0), "lower-left: {x0},{y0}");
assert!(
close(x1, 200.0) && close(y1, 100.0),
"upper-right: {x1},{y1}"
);
}
/// §12.5.5 step 1: the `/BBox` is transformed by `/Matrix` *first*, and
/// the transformed box is what gets fitted. A 90-degree rotation swaps
/// the box's width and height, so fitting the untransformed box scales
/// it wrongly — the classic "rotated stamp is squashed" bug.
#[test]
fn a_rotated_appearance_fits_its_transformed_box() {
// Rotate 90 degrees: [0 1 -1 0 0 0].
let rotate: Matrix = [0.0, 1.0, -1.0, 0.0, 0.0, 0.0];
let m = appearance_matrix([0.0, 0.0, 100.0, 50.0], rotate, [0.0, 0.0, 50.0, 100.0]);
// Every corner of the original box must land inside /Rect.
for (x, y) in [(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)] {
let (px, py) = apply(&m, x, y);
assert!(
(-1e-6..=50.0 + 1e-6).contains(&px) && (-1e-6..=100.0 + 1e-6).contains(&py),
"corner ({x},{y}) landed at ({px},{py}), outside the 50x100 rect"
);
}
// And the transformed box must fill it, not sit in a corner.
let (ax, ay) = apply(&m, 0.0, 0.0);
let (bx, by) = apply(&m, 100.0, 50.0);
assert!(close((ax - bx).abs(), 50.0), "width {}", (ax - bx).abs());
assert!(close((ay - by).abs(), 100.0), "height {}", (ay - by).abs());
}
#[test]
fn a_scaling_matrix_is_accounted_for() {
let scale: Matrix = [2.0, 0.0, 0.0, 2.0, 0.0, 0.0];
let m = appearance_matrix([0.0, 0.0, 50.0, 50.0], scale, [0.0, 0.0, 100.0, 100.0]);
// The /Matrix already doubles it to 100x100, so fitting a 100x100
// rect needs no further scaling.
let (x1, y1) = apply(&m, 50.0, 50.0);
assert!(close(x1, 100.0) && close(y1, 100.0), "{x1},{y1}");
}
/// A degenerate box collapses to a line and cannot be scaled to fill a
/// rectangle. It must not produce NaN, which renders as nothing.
#[test]
fn a_degenerate_bbox_does_not_produce_nan() {
let m = appearance_matrix([0.0, 0.0, 0.0, 100.0], IDENTITY, [0.0, 0.0, 50.0, 50.0]);
for (i, v) in m.iter().enumerate() {
assert!(v.is_finite(), "component {i} is {v}");
}
}
/// Reversed corners are legal in both boxes and must be normalised.
#[test]
fn reversed_corners_are_normalised() {
let forward = appearance_matrix([0.0, 0.0, 1.0, 1.0], IDENTITY, [0.0, 0.0, 100.0, 50.0]);
let reversed = appearance_matrix([1.0, 1.0, 0.0, 0.0], IDENTITY, [100.0, 50.0, 0.0, 0.0]);
assert_matrix(reversed, forward);
}
#[test]
fn scope_selects_the_right_annotations() {
use crate::annotations::AnnotationType;
let make = |kind: AnnotationType| PdfAnnotation {
annot_type: kind,
rect: [0.0, 0.0, 10.0, 10.0],
flags: Default::default(),
page_index: None,
obj_ref: None,
raw_dict: PdfDict::new(),
};
let widget = make(AnnotationType::Widget);
let text = make(AnnotationType::Text { contents: None });
assert!(FlattenScope::All.includes(&widget));
assert!(FlattenScope::All.includes(&text));
assert!(FlattenScope::WidgetsOnly.includes(&widget));
assert!(!FlattenScope::WidgetsOnly.includes(&text));
assert!(!FlattenScope::AnnotationsOnly.includes(&widget));
assert!(FlattenScope::AnnotationsOnly.includes(&text));
}
#[test]
fn hidden_and_noview_annotations_are_not_visible() {
let mut d = PdfDict::new();
assert!(is_visible(&d), "no /F means visible");
d.set("F", PdfObj::Int(flags::HIDDEN));
assert!(!is_visible(&d), "/Hidden must not be burned in");
d.set("F", PdfObj::Int(flags::NO_VIEW));
assert!(!is_visible(&d), "/NoView must not be burned in");
d.set("F", PdfObj::Int(4)); // /Print
assert!(is_visible(&d), "/Print alone is visible");
}
#[test]
fn skip_reasons_describe_themselves() {
assert!(SkipReason::NoAppearance.to_string().contains("/AP"));
assert!(SkipReason::NotVisible.to_string().contains("hidden"));
assert!(SkipReason::PopupWindow.to_string().contains("pop-up"));
assert!(SkipReason::DegenerateRect.to_string().contains("/Rect"));
}
#[test]
fn a_matrix_is_read_or_defaults_to_identity() {
let mut d = PdfDict::new();
assert_eq!(matrix_of(&d), IDENTITY, "absent /Matrix is the identity");
d.set(
"Matrix",
PdfObj::Array(vec![
PdfObj::Int(2),
PdfObj::Int(0),
PdfObj::Int(0),
PdfObj::Int(2),
PdfObj::Int(5),
PdfObj::Int(5),
]),
);
assert_eq!(matrix_of(&d), [2.0, 0.0, 0.0, 2.0, 5.0, 5.0]);
// A short or malformed matrix falls back rather than reading past
// the end.
d.set("Matrix", PdfObj::Array(vec![PdfObj::Int(1)]));
assert_eq!(matrix_of(&d), IDENTITY);
}
}