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.
255 lines
9.9 KiB
Rust
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}");
|
|
}
|