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