nigig-org/crates/apps/pdf/pdf-graphics/tests/document_creation.rs
andodeki 1220f89fc6
Some checks failed
email.yml / feat(pdf): close the last three Phase 4 items — reconciliation, CFF, cmap (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): close the last three Phase 4 items — reconciliation, CFF, cmap
The three items the previous commit's audit found unimplemented while the
status line said "complete". All three are done and externally verified.

**1. Field-value reconciliation** (`reconcile.rs`).

A field carries its value in /V and its rendered look in /AP, and nothing
in the format keeps them in step. Files arrive with them disagreeing all
the time: a producer writes /V and leaves appearances to the viewer, or
something edits /V without touching /AP. Until now this crate simply
believed /V and regenerated appearances only for fields it had itself
edited — right for a field we changed, wrong for a field that arrived
inconsistent.

The module deliberately does **not** pick a winner. PDF 32000-1 §12.7.3.3
settles exactly one case — /NeedAppearances true means /V is authoritative
— and is silent on the other, where a conforming viewer renders /AP and
never looks at /V. So it classifies the disagreement and resolves it
against a caller-declared `Intent`, because the right answer genuinely
differs: a viewer must show /AP to match other viewers, an extractor must
read /V, an editor must regenerate so the saved file agrees with itself.
Silently choosing one would be ADR 0017's failure in a new place — every
answer plausible, none checkable, the caller unaware a decision was made
for it.

Two cases are not judgement calls and are handled outright. A missing or
dangling appearance renders *blank*, and blank is never what the producer
meant, so even Display regenerates. An unselected radio member showing
/Off while the group's /V names another member is correct, not a
conflict — reporting it would flag every well-built radio group there is.

**2. Type1/CFF embedding** (`embed_opentype_whole`).

The spec says "if feasible". Subsetting CFF is not — it means rebuilding
the CFF INDEX, charset and charstrings, a second font format inside the
first — and `subset_truetype` rightly keeps refusing it by name. Embedding
the program *whole* is feasible, and that is what this does: /FontFile3
with /Subtype /OpenType under a CIDFontType0 descendant, per Table 126.

Each of those keys matters and none is guessable from the others. A CFF
program in /FontFile2, or under a CIDFontType2 descendant, still produces
a file qpdf accepts and a font that loads as the wrong type or not at all.
/CIDToGIDMap is omitted because it is defined for CIDFontType2 only.

The trade is made visible rather than buried: `EmbeddedFont::is_subsetted`
is false here, so a caller with a size budget — or a licence that forbids
shipping a whole face — can refuse instead of discovering it from the
output size.

**3. `repair-cmap`** (`glyph_index`).

A symbol font declares no Unicode subtable: it maps glyphs into the
private-use area at 0xF000 + the low byte under platform 3, encoding 0.
Asking it for 'A' found nothing and the character silently vanished from
the output — the font "missing" a glyph it plainly has. Now the (3,0)
subtable is kept as a fallback and retried at 0xF000 + low byte, after the
proper lookup fails so a font with both subtables is still read through
the Unicode one. Format 0 is read too; omitting it left legacy and symbol
fonts mapping nothing while appearing to have a usable cmap.

The repair must not manufacture glyphs, which is its own test: a character
the font genuinely lacks still returns None, because turning a missing
character into a wrong one is worse.

**Fixtures.** No CFF or symbol font ships on the CI image, and neither can
be tested honestly against a hand-built stub — the point is that the bytes
are a font program a third-party reader accepts. Both are generated from
DejaVu by checked-in fontTools scripts: `cff_sample.otf` (1.6 KB, real
OTTO/CFF outlines) and `symbol_sample.ttf` (664 B, a single (3,0) subtable
so the repair path is the only route to its glyphs).

Both generators pin `head.created`/`head.modified` to zero. fontTools
stamps the current time, so the output differed on every run and CI's
"fixtures match their generator" check failed against a file nobody had
edited. Caught by running that check rather than assuming it passed. A
fixture that cannot be regenerated byte-for-byte is not reviewable: you
cannot tell a deliberate change from a rebuild.

**Verified by mutation**, seven injected defects, each confirmed red:

  NeedAppearances ignored              1 fail
  dangling /AS not detected            1 fail
  blank rendering shown faithfully     1 fail
  CFF written to /FontFile2            1 fail
  CFF given a CIDFontType2 descendant  1 fail
  whole font claims to be subset       1 fail
  cmap 0xF000 retry removed            3 fail

**Verified externally.** The sample now carries a third page set in the
whole-embedded CFF font, and `check-pdf-external-readers.sh` gained
`pdffonts` — the only check that inspects a font *program* rather than the
file structure, which is exactly where a wrong /FontFile key shows up.
poppler reports both fonts embedded and distinguishes them correctly:

  ETXLDI+DejaVuSans   CID TrueType      Identity-H   emb yes  sub yes
  NigigTestCFF        CID Type 0C (OT)  Identity-H   emb yes  sub no

and extracts "Hello CFF 123", which only works if the CFF program loaded,
/Identity-H addressed its glyphs and /ToUnicode mapped them back.

That check also caught its own page-count assertion going stale when the
third page landed — a gate that notices its own fixture changing is
working.

Engine suite 953 -> 985. Coverage 87.27%, all floors met.

Phase 4 is complete but for the ui.rs interaction tests, which are written
and blocked on the Makepad fork's missing headless backend.
2026-08-17 09:31:41 +00:00

1567 lines
56 KiB
Rust

//! Phase 4: creating documents, read back through our own parser.
//!
//! A writer can only be tested by reading what it wrote. Every test here
//! generates a file and then parses it with `PdfDocument`, asserting
//! **values** — page sizes, field values, destination targets — rather than
//! that the bytes contain a substring. Checking for `/Type /Page` in the
//! output proves nothing: the old builder emitted that and still discarded
//! every page size it was given.
//!
//! See `REVIEWS/adr/0019-pdf-document-creation.md`.
use std::collections::BTreeSet;
use nigig_pdf_cos::object::{PdfDict, PdfObj, PdfStream};
use nigig_pdf_cos::writer::{
Attachment, DocumentMetadata, OutlineItem, PageLabelRange, PageLabelStyle, PageLayout,
PageMode, PdfDocBuilder, ViewerPreferences,
};
use nigig_pdf_document::form::{FieldType, FieldValue};
use nigig_pdf_document::PdfDocument;
use nigig_pdf_graphics::content_writer::ContentWriter;
use nigig_pdf_graphics::create::{DocumentCreator, NewField, NewFieldKind};
const FONT: &str = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
fn font_bytes() -> Option<Vec<u8>> {
std::fs::read(FONT).ok()
}
// ------------------------------------------------------------- structure
#[test]
fn a_generated_document_parses_back_with_the_page_sizes_it_was_given() {
// The regression: `add_page_with_content` took a width and a height,
// named the parameters `_width` and `_height`, and wrote no /MediaBox
// at all. Every page came back as US Letter whatever the caller asked
// for, and nothing noticed because no test read the size back.
let mut builder = PdfDocBuilder::new();
builder.add_page(200.0, 400.0, b"1 0 0 rg 0 0 10 10 re f");
builder.add_page(300.5, 500.25, b"0 0 1 rg 0 0 10 10 re f");
let pdf = builder.finish();
let mut doc = PdfDocument::parse(&pdf).expect("generated file must parse");
assert_eq!(doc.page_count(), 2);
assert_eq!(doc.page(0).unwrap().media_box, [0.0, 0.0, 200.0, 400.0]);
assert_eq!(doc.page(1).unwrap().media_box, [0.0, 0.0, 300.5, 500.25]);
}
#[test]
fn page_content_survives_the_round_trip() {
let mut builder = PdfDocBuilder::new();
let mut cw = ContentWriter::new();
cw.rgb_fill(1.0, 0.0, 0.0);
cw.rectangle(10.0, 20.0, 30.0, 40.0);
cw.fill();
builder.add_page(200.0, 200.0, &cw.build());
let pdf = builder.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let page = doc.page(0).unwrap();
let text = String::from_utf8_lossy(&page.content_data);
assert!(text.contains("re"), "content was {text:?}");
assert!(text.contains("f"), "content was {text:?}");
assert!(text.contains("1 0 0 rg"), "content was {text:?}");
}
#[test]
fn an_uncompressed_document_is_still_readable() {
let mut builder = PdfDocBuilder::new();
builder.set_compress(false);
builder.add_page(100.0, 100.0, b"0 g 1 1 10 10 re f");
let pdf = builder.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
assert_eq!(
doc.page(0).unwrap().content_data,
b"0 g 1 1 10 10 re f".to_vec()
);
}
#[test]
fn a_document_with_no_pages_still_produces_a_valid_file() {
// An empty document is a legal thing to generate, and a writer that
// emits a broken file for it fails at the worst moment.
let pdf = PdfDocBuilder::new().finish();
let doc = PdfDocument::parse(&pdf).expect("an empty document must still parse");
assert_eq!(doc.page_count(), 0);
}
// -------------------------------------------------------------- metadata
#[test]
fn metadata_round_trips_through_the_info_dictionary() {
let mut builder = PdfDocBuilder::new();
builder.add_page(100.0, 100.0, b"");
builder.set_metadata(DocumentMetadata {
title: Some("The Title".to_string()),
author: Some("Ada Lovelace".to_string()),
subject: Some("A subject".to_string()),
keywords: Some("one two".to_string()),
creator: Some("nigig".to_string()),
producer: Some("nigig-pdf".to_string()),
creation_date: Some("D:20260816120000Z".to_string()),
mod_date: None,
});
let pdf = builder.finish();
let doc = PdfDocument::parse(&pdf).expect("parses");
let info = doc.info();
assert_eq!(info.title.as_deref(), Some("The Title"));
assert_eq!(info.author.as_deref(), Some("Ada Lovelace"));
assert_eq!(info.subject.as_deref(), Some("A subject"));
assert_eq!(info.keywords.as_deref(), Some("one two"));
assert_eq!(info.producer.as_deref(), Some("nigig-pdf"));
assert_eq!(info.creation_date.as_deref(), Some("D:20260816120000Z"));
}
#[test]
fn a_title_containing_pdf_syntax_is_escaped_not_corrupted() {
// Parentheses and backslashes end a literal string. Writing them raw
// truncates the title and, worse, leaves the rest of the file being
// parsed as string content.
let mut builder = PdfDocBuilder::new();
builder.add_page(100.0, 100.0, b"");
builder.set_metadata(DocumentMetadata {
title: Some(r"A (tricky\ title) here".to_string()),
..Default::default()
});
let pdf = builder.finish();
let doc = PdfDocument::parse(&pdf).expect("parses");
assert_eq!(doc.info().title.as_deref(), Some(r"A (tricky\ title) here"));
}
#[test]
fn a_dictionary_key_needing_escapes_still_round_trips() {
// Keys are names and must be escaped like names. Writing them raw
// produced `<</Weird Key 1/After 2>>`, which our own lexer rejects with
// "expected number" because `Key` reads as the value. A generated file
// that cannot be reparsed is the worst possible writer bug.
//
// A resource name reaches the output as a real dictionary key, unlike
// an attachment name, which is written as a string. Only the key path
// exercises the escaping.
let mut builder = PdfDocBuilder::new();
let page = builder.add_page(100.0, 100.0, b"/Odd Name Do");
let mut xobject = PdfDict::new();
xobject.set("Type", PdfObj::Name("XObject".to_string()));
xobject.set("Subtype", PdfObj::Name("Form".to_string()));
xobject.set("BBox", PdfObj::Array(vec![PdfObj::Int(0); 4]));
xobject.set("Length", PdfObj::Int(0));
let num = builder.add_object(PdfObj::Stream(PdfStream {
dict: xobject,
data: Vec::new(),
}));
// A space and a hash are both illegal bare in a name.
builder.add_page_resource(page, "XObject", "Odd Name#1", num);
let pdf = builder.finish();
let mut doc = PdfDocument::parse(&pdf).expect("a file with odd keys must reparse");
let page = doc.page(0).expect("page 0");
assert!(
page.xobjects.contains_key("Odd Name#1"),
"the escaped key must decode back to its original text, got {:?}",
page.xobjects.keys().collect::<Vec<_>>()
);
}
#[test]
fn an_attachment_name_with_pdf_syntax_survives() {
let mut builder = PdfDocBuilder::new();
builder.add_page(100.0, 100.0, b"");
builder.add_attachment(Attachment {
name: "a file (with) parens.txt".to_string(),
data: b"body".to_vec(),
description: None,
mime_type: Some("text/plain".to_string()),
});
let pdf = builder.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let attachments = doc.attachments().expect("attachments");
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].name, "a file (with) parens.txt");
assert_eq!(attachments[0].data, b"body");
// A MIME type is a name, so its slash is written #2F and must decode.
assert_eq!(attachments[0].mime_type.as_deref(), Some("text/plain"));
}
#[test]
fn a_non_finite_number_does_not_corrupt_the_file() {
// PDF has no syntax for NaN or infinity: `format!("{}", f64::NAN)`
// emits `NaN`, which a parser reads as a keyword and chokes on. One
// such value anywhere made the whole document unreadable.
let mut builder = PdfDocBuilder::new();
builder.add_page(f64::NAN, f64::INFINITY, b"");
let pdf = builder.finish();
let mut doc = PdfDocument::parse(&pdf).expect("must still parse");
let media = doc.page(0).expect("page 0").media_box;
assert!(
media.iter().all(|v| v.is_finite()),
"a non-finite page size must be clamped, got {media:?}"
);
}
// --------------------------------------------------------------- outline
#[test]
fn an_outline_tree_is_linked_so_every_entry_is_reachable() {
let mut builder = PdfDocBuilder::new();
for _ in 0..3 {
builder.add_page(200.0, 200.0, b"");
}
builder.set_outline(vec![
OutlineItem::new("Chapter 1", 0)
.opened()
.with_children(vec![
OutlineItem::new("Section 1.1", 1).with_top(500.0),
OutlineItem::new("Section 1.2", 2),
]),
OutlineItem::new("Chapter 2", 2),
]);
let pdf = builder.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let outlines = doc.outline().expect("an outline must be present");
assert_eq!(outlines.len(), 2, "two top-level entries");
assert_eq!(outlines[0].title, "Chapter 1");
assert_eq!(outlines[0].children.len(), 2);
assert_eq!(outlines[0].children[0].title, "Section 1.1");
assert_eq!(outlines[1].title, "Chapter 2");
// The destinations must resolve to the pages they were given.
assert_eq!(outlines[0].page_index, Some(0));
assert_eq!(outlines[0].children[0].page_index, Some(1));
assert_eq!(outlines[1].page_index, Some(2));
// /Count carries the expanded state in its *sign* (Table 153). A
// writer that always emits a negative count produces a tree that is
// structurally perfect and silently collapsed in every viewer.
assert!(
outlines[0].open,
"Chapter 1 was created open and must read back open"
);
assert!(
!outlines[1].open,
"Chapter 2 has no children and must not claim to be open"
);
}
// ---------------------------------------------------- named destinations
#[test]
fn named_destinations_resolve_to_their_pages() {
let mut builder = PdfDocBuilder::new();
for _ in 0..3 {
builder.add_page(200.0, 200.0, b"");
}
builder.add_named_destination("intro", 0, None);
builder.add_named_destination("appendix", 2, Some(150.0));
let pdf = builder.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let names: Vec<String> = doc
.named_destinations()
.into_iter()
.map(|(n, _)| n)
.collect();
assert_eq!(names, vec!["appendix".to_string(), "intro".to_string()]);
let intro = doc.lookup_named_destination("intro").expect("intro");
assert_eq!(doc.resolve_destination(&intro), Some(0));
let appendix = doc.lookup_named_destination("appendix").expect("appendix");
assert_eq!(doc.resolve_destination(&appendix), Some(2));
}
// ----------------------------------------------------------- page labels
#[test]
fn page_labels_are_written_as_a_number_tree() {
let mut builder = PdfDocBuilder::new();
for _ in 0..6 {
builder.add_page(200.0, 200.0, b"");
}
builder.set_page_labels(vec![
PageLabelRange {
start_page: 0,
style: PageLabelStyle::LowerRoman,
prefix: None,
first: None,
},
PageLabelRange {
start_page: 2,
style: PageLabelStyle::Decimal,
prefix: None,
first: Some(1),
},
PageLabelRange {
start_page: 4,
style: PageLabelStyle::UpperLetters,
prefix: Some("App-".to_string()),
first: None,
},
]);
let pdf = builder.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let labels = doc.page_labels().expect("labels present");
// i, ii, then 1, 2, then App-A, App-B.
assert_eq!(labels.label_for(0).as_deref(), Some("i"));
assert_eq!(labels.label_for(1).as_deref(), Some("ii"));
assert_eq!(labels.label_for(2).as_deref(), Some("1"));
assert_eq!(labels.label_for(3).as_deref(), Some("2"));
assert_eq!(labels.label_for(4).as_deref(), Some("App-A"));
assert_eq!(labels.label_for(5).as_deref(), Some("App-B"));
}
// ------------------------------------------------------ viewer behaviour
#[test]
fn viewer_preferences_and_modes_round_trip() {
let mut builder = PdfDocBuilder::new();
builder.add_page(100.0, 100.0, b"");
builder.set_page_mode(PageMode::UseOutlines);
builder.set_page_layout(PageLayout::TwoColumnLeft);
builder.set_viewer_preferences(ViewerPreferences {
display_doc_title: Some(true),
hide_toolbar: Some(true),
center_window: Some(false),
..Default::default()
});
let pdf = builder.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let prefs = doc.viewer_preferences().expect("preferences present");
assert_eq!(prefs.display_doc_title, Some(true));
assert_eq!(prefs.hide_toolbar, Some(true));
// Explicitly false must survive as false, not be dropped as "unset".
assert_eq!(prefs.center_window, Some(false));
assert_eq!(prefs.hide_menubar, None);
assert_eq!(doc.page_mode().as_deref(), Some("UseOutlines"));
assert_eq!(doc.page_layout().as_deref(), Some("TwoColumnLeft"));
}
// ------------------------------------------------------------ attachment
#[test]
fn an_attachment_can_be_read_back_byte_for_byte() {
let mut builder = PdfDocBuilder::new();
builder.add_page(100.0, 100.0, b"");
builder.add_attachment(Attachment {
name: "notes.txt".to_string(),
data: b"the quick brown fox".to_vec(),
description: Some("A note".to_string()),
mime_type: Some("text/plain".to_string()),
});
builder.add_attachment(Attachment {
name: "data.bin".to_string(),
data: vec![0u8, 255, 128, 1],
description: None,
mime_type: None,
});
let pdf = builder.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let attachments = doc.attachments().expect("attachments present");
assert_eq!(attachments.len(), 2);
let notes = attachments
.iter()
.find(|a| a.name == "notes.txt")
.expect("notes.txt");
assert_eq!(notes.data, b"the quick brown fox");
assert_eq!(notes.description.as_deref(), Some("A note"));
// Binary data must survive unmangled, including a zero byte.
let binary = attachments
.iter()
.find(|a| a.name == "data.bin")
.expect("data.bin");
assert_eq!(binary.data, vec![0u8, 255, 128, 1]);
}
// ---------------------------------------------------------- font embedding
#[test]
fn an_embedded_subset_font_is_a_usable_type0_font() {
let Some(font_data) = font_bytes() else {
return;
};
let mut creator = DocumentCreator::new();
let text = "Embedded";
let chars: BTreeSet<char> = text.chars().collect();
let font = creator
.embed_truetype("F1", &font_data, &chars)
.expect("embeds");
let mut cw = ContentWriter::new();
cw.begin_text();
cw.set_font("F1", 18.0);
cw.set_text_matrix(1.0, 0.0, 0.0, 1.0, 20.0, 100.0);
cw.show_glyph_hex(&font.encode(text));
cw.end_text();
creator.builder().add_page(300.0, 150.0, &cw.build());
let pdf = creator.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let page = doc.page(0).expect("page 0");
let resource = page.fonts.get("F1").expect("F1 must be in the resources");
assert_eq!(resource.subtype, "Type0");
assert!(
resource.base_font.ends_with("DejaVuSans"),
"the base font should name the real family, got {}",
resource.base_font
);
// A subset tag is six capitals and a plus.
assert_eq!(
resource.base_font.chars().nth(6),
Some('+'),
"missing subset tag in {}",
resource.base_font
);
assert_eq!(
resource.descendant_fonts.len(),
1,
"a Type0 font needs exactly one descendant CIDFont"
);
}
#[test]
fn an_embedded_font_carries_a_to_unicode_map_so_text_is_extractable() {
let Some(font_data) = font_bytes() else {
return;
};
let mut creator = DocumentCreator::new();
let chars: BTreeSet<char> = "Hi".chars().collect();
let font = creator
.embed_truetype("F1", &font_data, &chars)
.expect("embeds");
creator.builder().add_page(100.0, 100.0, b"");
let pdf = creator.finish();
// Without /ToUnicode a subset renders correctly and copies out as
// gibberish, because the content stream holds glyph ids.
let text = String::from_utf8_lossy(&pdf);
assert!(text.contains("/ToUnicode"), "no /ToUnicode in the output");
assert!(text.contains("beginbfchar"), "the CMap has no mappings");
assert!(text.contains("Identity-H"));
assert!(text.contains("/CIDToGIDMap /Identity"));
assert!(
text.contains("/FontFile2"),
"the font program is not embedded"
);
// And the glyph ids used are the ones the encoder produces.
assert_eq!(font.encode("Hi").len(), 4);
}
#[test]
fn a_standard_font_needs_no_embedding() {
let mut creator = DocumentCreator::new();
creator.add_standard_font("Helv", "Helvetica");
let mut cw = ContentWriter::new();
cw.begin_text();
cw.set_font("Helv", 12.0);
cw.text_at(10.0, 50.0, "Plain text");
cw.end_text();
creator.builder().add_page(200.0, 100.0, &cw.build());
let pdf = creator.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let page = doc.page(0).expect("page 0");
let font = page.fonts.get("Helv").expect("Helv present");
assert_eq!(font.subtype, "Type1");
assert_eq!(font.base_font, "Helvetica");
}
// ------------------------------------------------------ AcroForm creation
fn form_document() -> Vec<u8> {
let mut creator = DocumentCreator::new();
creator.builder().add_page(400.0, 700.0, b"");
creator.add_field(NewField {
name: "fullname".to_string(),
kind: NewFieldKind::Text {
value: "Ada".to_string(),
multiline: false,
max_len: Some(40),
},
rect: [50.0, 600.0, 300.0, 625.0],
page: 0,
read_only: false,
required: true,
default_appearance: None,
});
creator.add_field(NewField {
name: "agree".to_string(),
kind: NewFieldKind::Checkbox { checked: true },
rect: [50.0, 560.0, 68.0, 578.0],
page: 0,
read_only: false,
required: false,
default_appearance: None,
});
creator.add_field(NewField {
name: "colour".to_string(),
kind: NewFieldKind::Radio {
options: vec![
("red".to_string(), [50.0, 520.0, 68.0, 538.0]),
("blue".to_string(), [90.0, 520.0, 108.0, 538.0]),
],
selected: Some("blue".to_string()),
},
rect: [0.0; 4],
page: 0,
read_only: false,
required: false,
default_appearance: None,
});
creator.add_field(NewField {
name: "country".to_string(),
kind: NewFieldKind::Choice {
options: vec!["Kenya".to_string(), "Uganda".to_string()],
selected: Some("Kenya".to_string()),
combo: true,
},
rect: [50.0, 470.0, 250.0, 495.0],
page: 0,
read_only: false,
required: false,
default_appearance: None,
});
creator.add_field(NewField {
name: "signature".to_string(),
kind: NewFieldKind::Signature,
rect: [50.0, 400.0, 250.0, 450.0],
page: 0,
read_only: false,
required: false,
default_appearance: None,
});
creator.finish()
}
#[test]
fn every_created_field_reads_back_with_its_type_and_value() {
let pdf = form_document();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let form = doc.acroform().expect("no error").expect("a form");
assert_eq!(form.field_count(), 5);
let name = form.find_field("fullname").expect("fullname");
assert_eq!(name.field_type, FieldType::Text);
assert_eq!(name.value, FieldValue::Text("Ada".to_string()));
assert!(name.is_required());
assert_eq!(name.max_len, Some(40));
let agree = form.find_field("agree").expect("agree");
assert_eq!(agree.field_type, FieldType::Checkbox);
assert!(agree.is_checked(), "the checkbox was created checked");
let colour = form.find_field("colour").expect("colour");
assert_eq!(colour.field_type, FieldType::Radio);
assert_eq!(colour.value, FieldValue::State("blue".to_string()));
assert_eq!(
colour.widgets.len(),
2,
"a radio group has one widget per option"
);
let country = form.find_field("country").expect("country");
assert_eq!(country.field_type, FieldType::ComboBox);
assert_eq!(country.value, FieldValue::Text("Kenya".to_string()));
assert_eq!(
country.options,
vec!["Kenya".to_string(), "Uganda".to_string()]
);
let signature = form.find_field("signature").expect("signature");
assert_eq!(signature.field_type, FieldType::Signature);
}
#[test]
fn radio_widgets_are_kids_of_one_field_not_separate_fields() {
// Mutual exclusion comes from the widgets sharing a parent field: the
// parent holds /V and each kid holds only its /AS. Widgets written
// without a /Parent become orphans, so selecting one does not clear
// the others and the group reads back as the wrong shape.
let pdf = form_document();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let form = doc.acroform().expect("no error").expect("a form");
// Exactly one field named "colour", not one per option.
let radios: Vec<_> = form
.fields()
.iter()
.filter(|f| f.field_type == FieldType::Radio)
.collect();
assert_eq!(radios.len(), 1, "a radio group is one field");
assert_eq!(radios[0].widgets.len(), 2);
assert_eq!(radios[0].value, FieldValue::State("blue".to_string()));
// The selected option's widget is the one whose on-state is "blue".
let on_states: Vec<Option<&str>> = radios[0].widgets.iter().map(|w| w.on_state()).collect();
assert!(
on_states.contains(&Some("red")) && on_states.contains(&Some("blue")),
"each widget needs its own export value, got {on_states:?}"
);
}
#[test]
fn every_field_widget_is_an_annotation_on_its_page() {
// A field that is in /Fields but not in the page's /Annots is invisible
// and unclickable: the form data is there and the user cannot reach it.
let pdf = form_document();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let annots = doc.page_annotations(0).expect("annotations");
// 4 single-widget fields plus 2 radio options = 6 widgets, minus the
// signature which is also a widget: 4 + 2 = 6.
assert_eq!(
annots.len(),
6,
"expected one annotation per widget, got {:?}",
annots.iter().map(|a| &a.annot_type).collect::<Vec<_>>()
);
let form = doc.acroform().expect("no error").expect("a form");
for (field, widget) in form.widgets_on_page(0) {
assert_eq!(
widget.page_index,
Some(0),
"widget of {} is not on page 0",
field.full_name
);
}
}
#[test]
fn created_fields_have_appearance_streams() {
// Without an appearance a field renders as nothing in any viewer that
// does not honour /NeedAppearances, which is most of them.
let pdf = form_document();
let text = String::from_utf8_lossy(&pdf);
assert!(text.contains("/AP"), "no appearance dictionaries emitted");
assert!(text.contains("/Tx BMC"), "no text field appearance");
assert!(
text.contains("/Subtype /Form"),
"appearances must be form XObjects"
);
}
#[test]
fn a_checkbox_appearance_has_both_an_on_and_an_off_state() {
// A checkbox with only an /Off state cannot show as ticked; one with
// only the on state renders a permanent tick.
let mut creator = DocumentCreator::new();
creator.builder().add_page(100.0, 100.0, b"");
creator.add_field(NewField {
name: "box".to_string(),
kind: NewFieldKind::Checkbox { checked: false },
rect: [10.0, 10.0, 28.0, 28.0],
page: 0,
read_only: false,
required: false,
default_appearance: None,
});
let pdf = creator.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let form = doc.acroform().expect("no error").expect("a form");
let field = form.find_field("box").expect("box");
let widget = &field.widgets[0];
let mut states = widget.appearance_states.clone();
states.sort();
assert_eq!(states, vec!["Off".to_string(), "Yes".to_string()]);
assert!(!field.is_checked(), "created unchecked");
}
#[test]
fn every_widget_carries_what_a_renderer_needs_to_draw_it() {
// Found by rendering the sample through PDFium: the fields were
// structurally perfect, pypdf read every value, and the page came out
// blank. Three things were missing, none of which a structural reader
// consults:
//
// /F with the Print bit - default is off, so the annotation is
// skipped on the printable layer
// /P naming its page - several renderers refuse a widget without
// AP /Resources - the stream says `/Helv 12 Tf`, and a form
// XObject with an undefined font is dropped
// whole
//
// Plus /MK and /BS, without which a field has no visible boundary.
let pdf = form_document();
let text = String::from_utf8_lossy(&pdf);
assert!(text.contains("/FormType 1"), "appearances need /FormType");
assert!(
text.contains("/Matrix"),
"appearances need an identity /Matrix"
);
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let annots = doc.page_annotations(0).expect("annotations");
assert!(!annots.is_empty());
for annot in &annots {
assert!(
annot.flags.print,
"a widget without the Print flag is skipped by renderers"
);
assert_eq!(
annot.page_index,
Some(0),
"every widget must know which page it is on"
);
// page_index is supplied by the *reader*, which knows the page it
// was asked about, so it cannot witness a missing /P. The written
// dictionary must carry the back-reference itself.
assert!(
annot.raw_dict.get("P").is_some(),
"widget {:?} has no /P naming its page",
annot.raw_dict.get_str("T").map(String::from_utf8_lossy)
);
let dict = &annot.raw_dict;
assert!(dict.get("MK").is_some(), "no /MK, so no visible border");
assert!(dict.get("BS").is_some(), "no /BS, so no border style");
assert!(dict.get("AP").is_some(), "no appearance at all");
}
}
#[test]
fn a_text_appearance_stream_declares_the_font_it_uses() {
// The appearance says `/Helv 12 Tf`. A form XObject that names a font
// its /Resources does not define is invalid, and PDFium discards the
// entire stream rather than substituting - the field drew nothing.
let pdf = form_document();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let max = doc.max_object_number();
let mut checked = 0usize;
for num in 1..=max {
let Ok(obj) = doc.resolve_obj_num(num) else {
continue;
};
let Some(stream) = obj.as_stream() else {
continue;
};
if stream.dict.get_name("Subtype") != Some("Form") {
continue;
}
let body = String::from_utf8_lossy(&stream.data);
if !body.contains("Tf") {
continue; // A border-only appearance needs no font.
}
let resources = stream
.dict
.get_dict("Resources")
.expect("an appearance using a font needs /Resources");
let fonts = resources
.get_dict("Font")
.expect("/Resources must declare /Font");
assert!(
fonts.map.contains_key("Helv"),
"the appearance names /Helv but declares {:?}",
fonts.map.keys().collect::<Vec<_>>()
);
checked += 1;
}
assert!(
checked >= 2,
"expected the text and choice appearances, checked {checked}"
);
}
#[test]
fn a_radio_button_is_drawn_as_a_circle_not_a_tick() {
// A radio group that renders as square ticks tells the reader they may
// pick several, which is the opposite of what the field enforces.
let pdf = form_document();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let form = doc.acroform().expect("no error").expect("a form");
let colour = form.find_field("colour").expect("colour");
assert_eq!(colour.widgets.len(), 2);
// Curve operators mean a circle; a checkbox appearance is straight
// lines only.
let mut found_curve = false;
let max = doc.max_object_number();
for num in 1..=max {
let Ok(obj) = doc.resolve_obj_num(num) else {
continue;
};
let Some(stream) = obj.as_stream() else {
continue;
};
if stream.dict.get_name("Subtype") != Some("Form") {
continue;
}
if String::from_utf8_lossy(&stream.data).contains(" c\n") {
found_curve = true;
}
}
assert!(
found_curve,
"no curved appearance was emitted, so the radios are not round"
);
}
#[test]
fn a_read_only_field_reads_back_as_read_only() {
let mut creator = DocumentCreator::new();
creator.builder().add_page(100.0, 100.0, b"");
creator.add_field(NewField {
name: "locked".to_string(),
kind: NewFieldKind::Text {
value: "fixed".to_string(),
multiline: false,
max_len: None,
},
rect: [10.0, 10.0, 90.0, 30.0],
page: 0,
read_only: true,
required: false,
default_appearance: None,
});
let pdf = creator.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let form = doc.acroform().expect("no error").expect("a form");
let field = form.find_field("locked").expect("locked");
assert!(field.is_read_only());
}
#[test]
fn a_multiline_field_is_flagged_multiline() {
let mut creator = DocumentCreator::new();
creator.builder().add_page(200.0, 200.0, b"");
creator.add_field(NewField {
name: "notes".to_string(),
kind: NewFieldKind::Text {
value: "line".to_string(),
multiline: true,
max_len: None,
},
rect: [10.0, 10.0, 190.0, 100.0],
page: 0,
read_only: false,
required: false,
default_appearance: None,
});
let pdf = creator.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let form = doc.acroform().expect("no error").expect("a form");
assert!(form.find_field("notes").expect("notes").is_multiline());
}
#[test]
fn a_document_can_carry_a_form_and_an_embedded_font_together() {
// The two features allocate object numbers independently. If they
// collide, one silently overwrites the other.
let Some(font_data) = font_bytes() else {
return;
};
let mut creator = DocumentCreator::new();
let chars: BTreeSet<char> = "Label".chars().collect();
let font = creator
.embed_truetype("F1", &font_data, &chars)
.expect("embeds");
let mut cw = ContentWriter::new();
cw.begin_text();
cw.set_font("F1", 12.0);
cw.set_text_matrix(1.0, 0.0, 0.0, 1.0, 20.0, 150.0);
cw.show_glyph_hex(&font.encode("Label"));
cw.end_text();
creator.builder().add_page(300.0, 200.0, &cw.build());
creator.add_field(NewField {
name: "field".to_string(),
kind: NewFieldKind::Text {
value: "value".to_string(),
multiline: false,
max_len: None,
},
rect: [20.0, 100.0, 200.0, 125.0],
page: 0,
read_only: false,
required: false,
default_appearance: None,
});
let pdf = creator.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let page = doc.page(0).expect("page 0");
assert_eq!(page.fonts.get("F1").expect("F1").subtype, "Type0");
let form = doc.acroform().expect("no error").expect("a form");
assert_eq!(
form.find_field("field").expect("field").value,
FieldValue::Text("value".to_string())
);
}
#[test]
fn no_object_number_is_written_twice() {
// Object numbers are allocated by three independent schemes: the fixed
// page block, `add_object`/`reserve_object`, and the trees allocated in
// `finish`. Twice these overlapped, and the symptom was not a crash:
// the later object silently replaced the earlier one, so `/F1` in a
// page's resources resolved to the name tree and `/ToUnicode` to a font
// descriptor. Every reference pointed at a real object; each named the
// wrong one.
//
// Parsing cannot detect this - both writes are well-formed - so the
// check is on the bytes.
let Some(font_data) = font_bytes() else {
return;
};
let mut creator = DocumentCreator::new();
let chars: BTreeSet<char> = "Collide".chars().collect();
creator
.embed_truetype("F1", &font_data, &chars)
.expect("embeds");
creator.add_standard_font("Helv", "Helvetica");
creator.builder().add_page(200.0, 200.0, b"");
creator.add_field(NewField {
name: "field".to_string(),
kind: NewFieldKind::Text {
value: "v".to_string(),
multiline: false,
max_len: None,
},
rect: [10.0, 10.0, 100.0, 30.0],
page: 0,
read_only: false,
required: false,
default_appearance: None,
});
{
let builder = creator.builder();
builder.set_outline(vec![OutlineItem::new("Top", 0)]);
builder.add_named_destination("here", 0, None);
builder.add_attachment(Attachment {
name: "a.txt".to_string(),
data: b"x".to_vec(),
description: None,
mime_type: None,
});
builder.set_metadata(DocumentMetadata {
title: Some("T".to_string()),
..Default::default()
});
}
let pdf = creator.finish();
// Scan for line-anchored "N 0 obj" headers.
let text = String::from_utf8_lossy(&pdf);
let mut seen: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
for line in text.lines() {
let mut parts = line.split_whitespace();
let (Some(num), Some(gen), Some(kw)) = (parts.next(), parts.next(), parts.next()) else {
continue;
};
if kw != "obj" || gen != "0" {
continue;
}
if let Ok(num) = num.parse::<u32>() {
*seen.entry(num).or_insert(0) += 1;
}
}
let mut duplicates: Vec<u32> = seen
.iter()
.filter(|(_, count)| **count > 1)
.map(|(num, _)| *num)
.collect();
duplicates.sort();
assert!(
!seen.is_empty(),
"the scanner found no objects at all, so it proves nothing"
);
assert!(
duplicates.is_empty(),
"these object numbers were written more than once: {duplicates:?}"
);
}
#[test]
fn a_font_resource_points_at_the_font_and_not_at_something_else() {
// The collision above made /F1 resolve to the /Names tree. The page
// parsed, the resource existed, and the font was not a font.
let Some(font_data) = font_bytes() else {
return;
};
let mut creator = DocumentCreator::new();
let chars: BTreeSet<char> = "Aa".chars().collect();
creator
.embed_truetype("F1", &font_data, &chars)
.expect("embeds");
creator.add_standard_font("Helv", "Helvetica");
creator.builder().add_page(200.0, 200.0, b"");
{
let builder = creator.builder();
builder.set_outline(vec![OutlineItem::new("Top", 0)]);
builder.add_named_destination("here", 0, None);
}
let pdf = creator.finish();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let page = doc.page(0).expect("page 0");
let f1 = page.fonts.get("F1").expect("F1 present");
assert_eq!(f1.subtype, "Type0", "F1 resolved to the wrong object");
assert_eq!(
f1.descendant_fonts.len(),
1,
"a Type0 font must name its descendant CIDFont"
);
let helv = page.fonts.get("Helv").expect("Helv present");
assert_eq!(helv.subtype, "Type1");
assert_eq!(helv.base_font, "Helvetica");
}
#[test]
fn a_generated_document_has_no_dangling_references() {
// Every reference in the file must resolve. A forward reference to an
// object number that was reserved and never written is the failure mode
// of out-of-order writing, and it makes a viewer reject the file.
let pdf = form_document();
let mut doc = PdfDocument::parse(&pdf).expect("parses");
let max = doc.max_object_number();
for num in 1..=max {
// Resolving must not error for an object the xref claims exists.
if let Err(e) = doc.resolve_obj_num(num) {
let message = e.to_string();
assert!(
message.contains("not found in xref"),
"object {num} is in the xref but unreadable: {message}"
);
}
}
}
// ------------------------------------------- images, headers and footers
use nigig_pdf_graphics::stamp::{
attach_image_to_page, combine_content, embed_image, footer_content, header_content,
image_stamp_content, Align, Banner, ImageSource,
};
/// A minimal but structurally real JPEG: SOI, SOF0 declaring the size, EOI.
fn tiny_jpeg(width: u16, height: u16, components: u8) -> Vec<u8> {
let mut v = vec![0xFF, 0xD8, 0xFF, 0xC0];
let len = 8 + 3 * components as u16;
v.extend_from_slice(&len.to_be_bytes());
v.push(8);
v.extend_from_slice(&height.to_be_bytes());
v.extend_from_slice(&width.to_be_bytes());
v.push(components);
for c in 0..components {
v.push(c + 1);
v.push(0x11);
v.push(0);
}
v.extend_from_slice(&[0xFF, 0xD9]);
v
}
/// The gap this closes: `ContentWriter::draw_image` has existed since
/// Phase 2, but nothing could create the XObject it names, so every `Do`
/// referred to a resource that did not exist. Reading the page back and
/// finding the image in `xobjects` is the assertion that proves the
/// resource is genuinely reachable — a substring check on `/Im0 Do` passed
/// throughout the period when no image could be embedded at all.
#[test]
fn an_embedded_image_is_reachable_from_the_page_that_draws_it() {
let mut builder = PdfDocBuilder::new();
let img = embed_image(
&mut builder,
"Im0",
ImageSource::Jpeg(tiny_jpeg(640, 480, 3)),
)
.expect("the image embeds");
let content = image_stamp_content(&img, 50.0, 100.0, 200.0, img.height_for_width(200.0));
let page = builder.add_page(612.0, 792.0, &content);
assert!(attach_image_to_page(&mut builder, page, &img));
let bytes = builder.finish();
let mut doc = PdfDocument::parse(&bytes).expect("the generated file parses");
let page = doc.page(0).expect("page 0");
let xobject = page
.xobjects
.get("Im0")
.expect("the page must deliver the XObject it declares");
assert_eq!(
xobject.subtype, "Image",
"the resource must be an image, not a form"
);
}
/// The image's own dimensions must survive into the file. A `/Width` that
/// disagrees with the JPEG codestream renders as diagonal garbage in every
/// viewer, and nothing in the file itself is invalid when it happens.
#[test]
fn an_embedded_jpeg_keeps_the_geometry_from_its_own_codestream() {
let mut builder = PdfDocBuilder::new();
// Deliberately not square, so a transposition is visible.
let img = embed_image(
&mut builder,
"Im0",
ImageSource::Jpeg(tiny_jpeg(800, 200, 3)),
)
.expect("embeds");
let page = builder.add_page(612.0, 792.0, b"");
attach_image_to_page(&mut builder, page, &img);
let bytes = builder.finish();
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let page = doc.page(0).expect("page 0");
let xref = page
.xobjects
.get("Im0")
.expect("the image resource")
.obj_ref;
let obj = doc.resolve(&PdfObj::Ref(xref)).expect("the image object");
let dict = match &obj {
PdfObj::Stream(s) => s.dict.clone(),
PdfObj::Dict(d) => d.clone(),
other => panic!("the image XObject is a {other:?}, not a stream"),
};
assert_eq!(
dict.get_int("Width"),
Some(800),
"width from the SOF marker"
);
assert_eq!(
dict.get_int("Height"),
Some(200),
"height from the SOF marker"
);
assert_eq!(dict.get_name("Filter"), Some("DCTDecode"));
assert_eq!(dict.get_name("ColorSpace"), Some("DeviceRGB"));
}
/// Raw samples embed as a lossless image with the colour space their
/// component count implies.
#[test]
fn a_raw_image_embeds_losslessly() {
let mut builder = PdfDocBuilder::new();
let img = embed_image(
&mut builder,
"Im0",
ImageSource::Raw {
width: 4,
height: 2,
components: 1,
samples: vec![0, 32, 64, 96, 128, 160, 192, 224],
},
)
.expect("embeds");
let page = builder.add_page(100.0, 100.0, b"");
attach_image_to_page(&mut builder, page, &img);
let bytes = builder.finish();
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let page = doc.page(0).expect("page 0");
let xref = page.xobjects.get("Im0").expect("resource").obj_ref;
let obj = doc.resolve(&PdfObj::Ref(xref)).expect("object");
let PdfObj::Stream(stream) = obj else {
panic!("a raw image must be a stream");
};
assert_eq!(stream.dict.get_int("Width"), Some(4));
assert_eq!(stream.dict.get_int("Height"), Some(2));
assert_eq!(stream.dict.get_name("ColorSpace"), Some("DeviceGray"));
assert_eq!(stream.dict.get_int("BitsPerComponent"), Some(8));
}
/// A header and a footer must land at opposite ends of the page. This
/// reads the *content stream* back and compares the two y coordinates,
/// rather than trusting the generator's arithmetic against itself.
#[test]
fn a_header_and_footer_land_at_opposite_ends_of_the_page() {
let header = Banner::new("Report", "F1", 12.0)
.aligned(Align::Left)
.with_margin(40.0);
let footer = Banner::new("Page 1", "F1", 12.0)
.aligned(Align::Left)
.with_margin(40.0);
let content = combine_content(&[
&header_content(&header, 612.0, 792.0),
&footer_content(&footer, 612.0),
]);
let mut builder = PdfDocBuilder::new();
builder.add_page(612.0, 792.0, &content);
builder.set_compress(false);
let bytes = builder.finish();
let mut doc = PdfDocument::parse(&bytes).expect("parses");
let page = doc.page(0).expect("page 0");
let text = String::from_utf8_lossy(&page.content_data).to_string();
assert!(
text.contains("752"),
"the header baseline must be 792 - 40 = 752: {text}"
);
assert!(
text.contains(" 40 "),
"the footer baseline must be the 40pt margin itself: {text}"
);
}
/// The whole Phase 4 exit criterion in one file: an embedded font, a
/// header, a footer and an image, all read back from the parsed document.
#[test]
fn a_stamped_document_round_trips_with_every_piece_intact() {
let mut builder = PdfDocBuilder::new();
let img = embed_image(
&mut builder,
"Logo",
ImageSource::Jpeg(tiny_jpeg(120, 60, 3)),
)
.expect("embeds");
let header = Banner::new("Quarterly report", "F1", 14.0);
let footer = Banner::new("Confidential", "F1", 9.0).aligned(Align::Right);
let content = combine_content(&[
&header_content(&header, 612.0, 792.0),
&footer_content(&footer, 612.0),
&image_stamp_content(&img, 72.0, 600.0, 120.0, 60.0),
]);
let page = builder.add_page(612.0, 792.0, &content);
attach_image_to_page(&mut builder, page, &img);
builder.set_compress(false);
let bytes = builder.finish();
let mut doc = PdfDocument::parse(&bytes).expect("parses");
assert_eq!(doc.page_count(), 1);
let page = doc.page(0).expect("page 0");
assert_eq!(page.width(), 612.0);
assert_eq!(page.height(), 792.0);
assert!(
page.xobjects.contains_key("Logo"),
"the image resource survived the round trip"
);
let text = String::from_utf8_lossy(&page.content_data).to_string();
assert!(text.contains("Quarterly report"), "header text");
assert!(text.contains("Confidential"), "footer text");
assert!(text.contains("/Logo Do"), "the image is painted");
assert!(
text.contains('q') && text.contains('Q'),
"the image stamp is isolated in a save/restore pair"
);
}
/// A banner naming an unregistered font produces a structurally valid PDF
/// that renders no text.
///
/// This is the failure mode the `Banner` docs warn about, pinned as a test
/// so the warning cannot drift from the behaviour. Found by running the
/// generated file through poppler, which reported `Unknown font tag 'F1'`
/// while `qpdf --check` passed — the file is not malformed, it just does
/// not say what the content stream needs.
///
/// The assertion is on the *resources*, because that is the thing that is
/// actually missing and the thing a caller can check. It deliberately does
/// not assert "no text renders": that would need a rasteriser.
#[test]
fn a_banner_font_must_be_registered_or_the_page_lacks_the_resource() {
let banner = Banner::new("Header text", "F1", 14.0);
// Without add_font: the page has no /Font resource at all.
let mut bare = PdfDocBuilder::new();
bare.add_page(612.0, 792.0, &header_content(&banner, 612.0, 792.0));
let bytes = bare.finish();
let mut doc = PdfDocument::parse(&bytes).expect("still a valid PDF");
let page = doc.page(0).expect("page 0");
assert!(
page.fonts.is_empty(),
"the content stream names /F1 but the page declares no fonts; \
this file opens cleanly and draws nothing"
);
// With add_font: the resource is there and named F1.
let mut wired = PdfDocBuilder::new();
let mut font = PdfDict::new();
font.set("Type", PdfObj::Name("Font".into()));
font.set("Subtype", PdfObj::Name("Type1".into()));
font.set("BaseFont", PdfObj::Name("Helvetica".into()));
wired.add_font("F1", PdfObj::Dict(font));
wired.add_page(612.0, 792.0, &header_content(&banner, 612.0, 792.0));
let bytes = wired.finish();
let mut doc = PdfDocument::parse(&bytes).expect("valid");
let page = doc.page(0).expect("page 0");
assert!(
page.fonts.contains_key("F1"),
"add_font must put /F1 in every page's resources, got {:?}",
page.fonts.keys().collect::<Vec<_>>()
);
}
// -------------------------------------------------------- CFF embedding
fn cff_font_bytes() -> Vec<u8> {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../tests/corpus/fonts/cff_sample.otf");
std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display()))
}
/// The subsetter must keep refusing CFF by name.
///
/// This is the behaviour ADR 0015's reasoning protects: a CFF program run
/// through a TrueType subsetter produces a font that is structurally a font
/// and renders nothing. Embedding whole is the alternative, not a reason to
/// loosen this.
#[test]
fn the_truetype_subsetter_still_refuses_cff_outlines() {
let chars: BTreeSet<char> = "Hello".chars().collect();
let err = nigig_pdf_graphics::subset::subset_truetype(&cff_font_bytes(), &chars)
.expect_err("CFF must not go through the TrueType subsetter");
assert!(
err.to_string().contains("CFF"),
"the refusal must name the format, got: {err}"
);
}
/// A CFF font embeds whole, and says so.
#[test]
fn a_cff_font_embeds_whole_and_reports_that_it_is_not_subset() {
let mut creator = DocumentCreator::new();
let chars: BTreeSet<char> = "Hello".chars().collect();
let font = creator
.embed_opentype_whole("C1", &cff_font_bytes(), &chars)
.expect("a CFF font embeds whole");
assert!(font.is_cff(), "the fixture is CFF-flavoured OpenType");
assert!(
!font.is_subsetted(),
"embedding whole must not claim to be a subset"
);
assert!(
!font.base_font.contains('+'),
"a whole font must not carry a six-letter subset tag: {}",
font.base_font
);
}
/// A TrueType font subset the normal way still reports itself as subset,
/// so the two paths are distinguishable by the caller.
#[test]
fn a_subset_truetype_font_reports_that_it_is_subset() {
let Some(data) = font_bytes() else {
eprintln!("skipping: DejaVuSans.ttf not installed");
return;
};
let mut creator = DocumentCreator::new();
let chars: BTreeSet<char> = "Hello".chars().collect();
let font = creator
.embed_truetype("F1", &data, &chars)
.expect("subsets");
assert!(font.is_subsetted());
assert!(!font.is_cff());
assert!(
font.base_font.contains('+'),
"a real subset must carry its tag: {}",
font.base_font
);
}
/// The structural half: CFF must land in `/FontFile3` with `/Subtype
/// /OpenType`, under a `CIDFontType0` descendant.
///
/// Getting any one of these wrong yields a font that parses and renders
/// nothing — the failure mode is silent, which is why each key is asserted
/// by name rather than the file merely being checked for validity.
#[test]
fn a_cff_font_writes_fontfile3_and_a_type0_descendant() {
let mut creator = DocumentCreator::new();
let chars: BTreeSet<char> = "Hello".chars().collect();
creator
.embed_opentype_whole("C1", &cff_font_bytes(), &chars)
.expect("embeds");
creator.builder().add_page(300.0, 200.0, b"BT ET");
let bytes = creator.finish();
let text = String::from_utf8_lossy(&bytes).to_string();
assert!(text.contains("/FontFile3"), "CFF goes in /FontFile3");
assert!(
!text.contains("/FontFile2"),
"a CFF program in /FontFile2 is rejected by every viewer"
);
assert!(
text.contains("/Subtype /OpenType"),
"the stream names its subtype"
);
assert!(
text.contains("/CIDFontType0"),
"CFF outlines need a CIDFontType0 descendant"
);
assert!(
!text.contains("/CIDToGIDMap"),
"/CIDToGIDMap is defined for CIDFontType2 only"
);
}
/// TrueType keeps its own shape, so the CFF branch cannot have changed it.
#[test]
fn a_truetype_font_still_writes_fontfile2_and_a_type2_descendant() {
let Some(data) = font_bytes() else {
eprintln!("skipping: DejaVuSans.ttf not installed");
return;
};
let mut creator = DocumentCreator::new();
let chars: BTreeSet<char> = "Hello".chars().collect();
creator
.embed_truetype("F1", &data, &chars)
.expect("subsets");
creator.builder().add_page(300.0, 200.0, b"BT ET");
let text = String::from_utf8_lossy(&creator.finish()).to_string();
assert!(text.contains("/FontFile2"));
assert!(text.contains("/Length1"), "/FontFile2 requires /Length1");
assert!(text.contains("/CIDFontType2"));
assert!(text.contains("/CIDToGIDMap"));
}
/// The embedded program must be the font's own bytes, unmodified.
///
/// Asserting the bytes appear verbatim is what distinguishes "embedded
/// whole" from "embedded something": a re-serialised font would still be a
/// font, still parse, and no longer be the file the caller supplied.
#[test]
fn the_whole_cff_program_is_embedded_verbatim() {
let data = cff_font_bytes();
let mut creator = DocumentCreator::new();
let chars: BTreeSet<char> = "Hello".chars().collect();
creator
.embed_opentype_whole("C1", &data, &chars)
.expect("embeds");
creator.builder().add_page(300.0, 200.0, b"BT ET");
let pdf = creator.finish();
assert!(
pdf.windows(data.len()).any(|w| w == data.as_slice()),
"the CFF program must appear verbatim in the file"
);
}
/// Glyph ids are the font's own, so /Identity-H addresses them directly.
#[test]
fn a_whole_font_keeps_the_fonts_own_glyph_ids() {
let data = cff_font_bytes();
let chars: BTreeSet<char> = "Hello".chars().collect();
let subset = nigig_pdf_graphics::subset::embed_opentype_whole(&data, &chars).expect("embeds");
for (old, new) in &subset.glyph_map {
assert_eq!(
old, new,
"a whole font must not renumber glyphs; {old} became {new}"
);
}
assert!(
subset.to_unicode.len() >= 4,
"the fixture covers H, e, l, o; got {} mappings",
subset.to_unicode.len()
);
}
// ------------------------------------------------------------ cmap repair
fn symbol_font_bytes() -> Vec<u8> {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../tests/corpus/fonts/symbol_sample.ttf");
std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display()))
}
/// `repair-cmap`: a symbol font's glyphs are reachable by their plain
/// characters.
///
/// The fixture declares only a `(3,0)` subtable mapping 0xF041 and 0xF042.
/// A lookup of 'A' can therefore only succeed through the 0xF000 + low byte
/// retry — there is no Unicode subtable for it to have come from, which is
/// what makes this test prove the repair rather than merely exercise it.
#[test]
fn a_symbol_font_maps_plain_characters_through_the_f000_retry() {
let data = symbol_font_bytes();
let font = nigig_pdf_graphics::sfnt::SfntFont::parse(&data).expect("parses");
let a = nigig_pdf_graphics::subset::glyph_index(&font, 'A')
.expect("'A' must resolve via the 0xF000 retry");
let b = nigig_pdf_graphics::subset::glyph_index(&font, 'B')
.expect("'B' must resolve via the 0xF000 retry");
assert_ne!(a, 0, "'A' must not land on .notdef");
assert_ne!(b, 0, "'B' must not land on .notdef");
assert_ne!(a, b, "the two characters are different glyphs");
}
/// The private-use codepoint still works directly: the repair is an
/// addition, not a replacement.
#[test]
fn a_symbol_font_still_maps_its_private_use_codepoints() {
let data = symbol_font_bytes();
let font = nigig_pdf_graphics::sfnt::SfntFont::parse(&data).expect("parses");
let direct = nigig_pdf_graphics::subset::glyph_index(&font, '\u{F041}')
.expect("the declared codepoint must resolve directly");
let repaired = nigig_pdf_graphics::subset::glyph_index(&font, 'A').expect("and via repair");
assert_eq!(direct, repaired, "both routes must reach the same glyph");
}
/// A character the font genuinely lacks must still return None. The repair
/// must not manufacture a glyph — that would turn a missing character into
/// a wrong one, which is worse.
#[test]
fn the_cmap_repair_does_not_invent_missing_glyphs() {
let data = symbol_font_bytes();
let font = nigig_pdf_graphics::sfnt::SfntFont::parse(&data).expect("parses");
assert_eq!(
nigig_pdf_graphics::subset::glyph_index(&font, 'Z'),
None,
"the fixture maps only A and B; Z must not resolve"
);
assert_eq!(
nigig_pdf_graphics::subset::glyph_index(&font, '\u{4E2D}'),
None
);
}
/// A normal Unicode font must not go anywhere near the repair path.
#[test]
fn a_unicode_font_is_unaffected_by_the_repair() {
let Some(data) = font_bytes() else {
eprintln!("skipping: DejaVuSans.ttf not installed");
return;
};
let font = nigig_pdf_graphics::sfnt::SfntFont::parse(&data).expect("parses");
let a = nigig_pdf_graphics::subset::glyph_index(&font, 'A').expect("'A' resolves");
assert_ne!(a, 0);
// U+F041 is unmapped in a normal text font, and the repair must not
// make it resolve to 'A' by running in reverse.
assert_ne!(
nigig_pdf_graphics::subset::glyph_index(&font, '\u{F041}'),
Some(a),
"the repair must not fire on a font with a real Unicode cmap"
);
}
/// A symbol font subsets and embeds end to end, so the repair is reachable
/// from the public API rather than only from `glyph_index`.
#[test]
fn a_symbol_font_subsets_through_the_public_api() {
let mut creator = DocumentCreator::new();
let chars: BTreeSet<char> = "AB".chars().collect();
let font = creator
.embed_truetype("S1", &symbol_font_bytes(), &chars)
.expect("a symbol font subsets");
assert_eq!(
font.subset.to_unicode.len(),
2,
"both requested characters must map to glyphs, got {:?}",
font.subset.to_unicode
);
assert!(
!font.encode("AB").is_empty(),
"the text encodes to glyph ids"
);
}