nigig-org/crates/apps/pdf/pdf-graphics/tests/nested_content.rs
andodeki d3089bc62a
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
feat(pdf): nested content, the wire codec and tiled rendering — Phase 7 closed
Three bullets, and the Phase 7 status table rewritten row by row.

**Nested content (ADR 0032).** A form XObject and a Type 3 glyph are the
same problem: a content stream inside a content stream. Both were parsed
completely and then not run. `paint_x_object` reported the name for "the
host" to resolve and no host existed, so `Do` painted nothing. Type 3 was
worse because it looked more correct — `d0`/`d1` reached the device, so the
pen advanced by the declared width and the page rendered an invisible line
of text with correct spacing after it.

`nested.rs` runs both, in pdf-graphics because the dependency runs graphics
→ document and this is the only crate that can see the interpreter and the
object model at once. Forms get their `/Matrix`, their `/BBox` clip and a
save/restore wrapper, because without the wrapper a form's colour leaks
into every object after it and looks like a bug in the document. Type 3
composes translate-then-matrix; the other order scales the translation and
puts the glyph at (1.7, 16.8) instead of (72, 700). Recursion is bounded in
both: unbounded, a self-referencing form is a stack overflow reachable from
an untrusted document, which is a denial of service and not a rendering bug.

**Wire codec and tiling (ADR 0033).** `worker.rs` moved interpretation off
the UI thread only because both ends shared a Vec. Tags are explicit
numbers, never declaration order, so reordering the enum cannot silently
make old recordings decode as different commands. Truncation is an error
rather than a short list — a decoder that stopped early would render a page
missing its last few operations, plausible and wrong.

The obvious truncation test failed, correctly: `Save` is one byte, so a cut
on a command boundary really is a complete list. It now tries every cut
position and requires each to be a named error or a genuine prefix.

Tile skipping is conservative. A command whose geometry is unknown is kept,
because dropping a state change corrupts everything after it in that tile,
silently. Only untransformed geometry that provably falls outside is
dropped. Every tile is asserted pixel-identical to that region of the
whole-page render: tiling that is fast and different is not an
optimisation.

Eight mutations across the two modules, all killed.

Phase 7 status is now two tables — the eight spec bullets and the exit
criteria — with what is missing named in the row rather than rounded up.
Three rows are not green: Makepad blend compositing needs render-to-texture,
the image-XObject pixel golden asserts the request rather than pixels, and
the `ui.rs` smoke tests remain blocked on the headless backend they have
been blocked on since Phase 1.

1425 tests pass, coverage 88.10% (was 87.60%), all floors met, external
readers pass.

ADRs 0032 and 0033.
2026-08-18 20:00:18 +00:00

255 lines
9.9 KiB
Rust

//! Form XObjects and Type 3 glyphs rendered from real corpus documents.
//!
//! Two Phase 7 bullets, one problem: a content stream nested inside
//! another. Both were parsed and then never run — `Do` reported a name and
//! `Type3Font::glyph_procedure` handed back bytes nobody interpreted — so
//! forms and Type 3 text both painted nothing, and a blank region is a
//! legal thing for a page to contain.
//!
//! `nested.rs`'s unit tests build a document inline. These start from the
//! checked-in corpus, so they also cover the part that made the defect
//! invisible: the resources actually resolving through the document layer.
//!
//! See `REVIEWS/adr/0032-pdf-nested-content.md`.
use std::path::PathBuf;
use nigig_pdf_cos::PdfDict;
use nigig_pdf_document::{extract_type3_fonts, PdfDocument};
use nigig_pdf_graphics::nested::{render_form, render_type3_glyph, NestedError};
use nigig_pdf_graphics::recording::{RecordingDevice, RenderCommand};
fn corpus(relative: &str) -> Vec<u8> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../tests/corpus")
.join(relative);
std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display()))
}
fn page_resources(doc: &mut PdfDocument) -> PdfDict {
let page_ref = doc.page_object_ref(0).expect("page 0");
let page_obj = doc.resolve_ref(page_ref).expect("resolves");
let dict = page_obj.as_dict().expect("a page dict").clone();
let resources = dict.get("Resources").expect("/Resources").clone();
doc.resolve(&resources)
.expect("resolves")
.as_dict()
.cloned()
.expect("a dict")
}
// ------------------------------------------------------- form XObjects
#[test]
fn a_corpus_form_xobject_paints_through_the_device() {
let data = corpus("images/xobject.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
let resources = page_resources(&mut doc);
// Whatever the fixture calls its XObject, every one of them must
// either run or be refused by name — never silently do nothing.
let names: Vec<String> = resources
.get("XObject")
.and_then(|o| doc.resolve(o).ok())
.and_then(|o| o.as_dict().cloned())
.map(|d| d.map.keys().cloned().collect())
.unwrap_or_default();
assert!(!names.is_empty(), "the fixture declares an XObject");
let mut ran = 0;
for name in &names {
let mut device = RecordingDevice::new();
match render_form(&mut doc, &mut device, &resources, name, 0) {
Ok(()) => {
let commands = device.into_commands();
assert!(
commands.len() > 2,
"/{name} ran but emitted only {} commands",
commands.len()
);
ran += 1;
}
// An image XObject is painted by the image path, and saying so
// by name is the difference between "we do not draw this" and
// "we drew nothing".
Err(NestedError::NotAForm(_)) => {}
Err(other) => panic!("/{name}: {other}"),
}
}
let _ = ran;
}
// ---------------------------------------------------------- Type 3 text
#[test]
fn a_type3_glyph_procedure_is_interpreted() {
// The regression. `type3/basic.pdf` glyph 'a' is `0 0 750 750 re f` —
// a filled square. Before this it never reached a device, so the page
// rendered blank while still advancing the pen by the declared width:
// invisible text with correct spacing after it.
let data = corpus("type3/basic.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
let resources = page_resources(&mut doc);
let fonts = extract_type3_fonts(&mut doc, &resources).expect("type 3 fonts");
let font = fonts.get("T3").expect("/T3 is a Type 3 font");
let mut device = RecordingDevice::new();
render_type3_glyph(&mut doc, &mut device, font, b'a', 24.0, 72.0, 700.0, 0)
.expect("the glyph runs");
let commands = device.into_commands();
assert!(
commands
.iter()
.any(|c| matches!(c, RenderCommand::FillWinding)),
"the glyph's fill never reached the device: {commands:?}"
);
assert!(
commands.iter().any(
|c| matches!(c, RenderCommand::Rectangle(_, _, w, h) if *w == 750.0 && *h == 750.0)
),
"the glyph's 750x750 rectangle is missing: {commands:?}"
);
}
#[test]
fn the_font_matrix_and_size_scale_the_glyph() {
// A Type 3 glyph is drawn in glyph space. With /FontMatrix
// [0.001 0 0 0.001 0 0] at 24pt, the composed transform is 0.024 —
// a reader that ignores either factor draws the glyph a thousand times
// too large or too small, which renders as a full-page block or as
// nothing.
let data = corpus("type3/basic.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
let resources = page_resources(&mut doc);
let fonts = extract_type3_fonts(&mut doc, &resources).expect("fonts");
let font = fonts.get("T3").expect("/T3");
let mut device = RecordingDevice::new();
render_type3_glyph(&mut doc, &mut device, font, b'a', 24.0, 72.0, 700.0, 0).expect("runs");
let commands = device.into_commands();
// The last transform before the rectangle is the composed one.
let transform = commands
.iter()
.take_while(|c| !matches!(c, RenderCommand::Rectangle(..)))
.filter_map(|c| match c {
RenderCommand::SetTransform(a, b, cc, d, e, f) => Some([*a, *b, *cc, *d, *e, *f]),
_ => None,
})
// `take_while` is not double-ended, so this really does have to
// walk the whole (short) prefix.
.last()
.expect("a transform must reach the device");
assert!(
(transform[0] - 0.024).abs() < 1e-9,
"0.001 x 24 is 0.024, got {transform:?}"
);
assert!(
(transform[3] - 0.024).abs() < 1e-9,
"the vertical scale must match, got {transform:?}"
);
// And the pen position is not scaled by the font matrix: it is in text
// space already. Composing in the other order would put the glyph at
// (1.7, 16.8) instead of (72, 700).
assert!(
(transform[4] - 72.0).abs() < 1e-9 && (transform[5] - 700.0).abs() < 1e-9,
"the glyph must be placed at the pen, got {transform:?}"
);
}
#[test]
fn a_custom_font_matrix_is_applied_as_written() {
// A Type 3 font may use any matrix — that is the point of the entry.
// Assuming 1/1000 is the mistake this fixture exists for.
let data = corpus("type3/custom_matrix.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
let resources = page_resources(&mut doc);
let fonts = extract_type3_fonts(&mut doc, &resources).expect("fonts");
let (name, font) = fonts.iter().next().expect("a Type 3 font");
assert_ne!(
font.font_matrix[0], 0.001,
"/{name} is the custom-matrix fixture; if it is 1/1000 the test proves nothing"
);
let mut device = RecordingDevice::new();
// Any code the font maps; the first one in its encoding.
let code = *font.encoding.keys().min().expect("an encoded code");
if render_type3_glyph(&mut doc, &mut device, font, code, 10.0, 0.0, 0.0, 0).is_ok() {
let commands = device.into_commands();
let transform = commands
.iter()
.filter_map(|c| match c {
RenderCommand::SetTransform(a, ..) => Some(*a),
_ => None,
})
.next_back()
.expect("a transform");
assert!(
(transform - font.font_matrix[0] * 10.0).abs() < 1e-9,
"the font's own matrix must be used, got {transform}"
);
}
}
#[test]
fn a_missing_glyph_procedure_is_refused_by_name() {
// `type3/missing_glyph.pdf` encodes a code whose /CharProcs entry is
// absent. Refusing by name is what lets a caller show a notdef box
// rather than a gap nobody can account for.
let data = corpus("type3/missing_glyph.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
let resources = page_resources(&mut doc);
let fonts = extract_type3_fonts(&mut doc, &resources).expect("fonts");
let font = fonts.values().next().expect("a font");
let mut refused = 0;
for code in 0u8..=255 {
if font.encoding.contains_key(&code) {
let mut device = RecordingDevice::new();
if render_type3_glyph(&mut doc, &mut device, font, code, 12.0, 0.0, 0.0, 0).is_err() {
refused += 1;
}
}
}
assert!(
refused > 0,
"the missing-glyph fixture must produce at least one named refusal"
);
}
#[test]
fn a_recursive_type3_glyph_is_bounded() {
// §9.6.5 forbids a Type 3 glyph referring to its own font; a malformed
// file does it anyway. Unbounded that is a stack overflow reachable
// from a document, which is a denial of service and not a rendering
// bug.
let data = corpus("type3/recursive.pdf");
let mut doc = PdfDocument::parse(&data).expect("parses");
let resources = page_resources(&mut doc);
let fonts = extract_type3_fonts(&mut doc, &resources).expect("fonts");
let font = fonts.values().next().expect("a font");
let code = *font.encoding.keys().min().expect("a code");
// The call must return — either drawing or refusing — and must not
// recurse forever. Reaching this assertion at all is the test.
let mut device = RecordingDevice::new();
let _ = render_type3_glyph(&mut doc, &mut device, font, code, 12.0, 0.0, 0.0, 0);
// And starting at the limit refuses outright.
let mut device = RecordingDevice::new();
let error = render_type3_glyph(
&mut doc,
&mut device,
font,
code,
12.0,
0.0,
0.0,
nigig_pdf_graphics::nested::MAX_NESTING_DEPTH,
)
.expect_err("at the depth limit it must refuse");
assert!(matches!(error, NestedError::TooDeep { .. }), "got {error}");
}