diff --git a/ROADMAP.md b/ROADMAP.md index d200583f59..a15fb5d69f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -240,6 +240,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886. +- **2026-11-07 (M2, #6449):** A non-embedded Times New Roman set as a TrueType CID font (`TimesNewRoman,Bold`, `TimesNewRomanPSMT` and the other faces) draws the glyphs its codes name, as the non-embedded Arial does: its first 258 glyphs follow the same standard Macintosh order, which the embedded Times New Roman subsets of a public French form confirm in 941 of 942 `/ToUnicode` entries (vendored hayro-interpret patch 88). That form drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" and "changement" as "ej angement". Times and Times New Roman MT Std are not read this way. + +- **2026-11-05 (M2, #5889):** A non-embedded Arial set as a TrueType CID font (`CIDFontType2`, `Identity-H`, as Word and PDFMaker write it) draws the glyphs its codes name. Those codes are glyph indexes in the author's Arial, whose first 258 glyphs follow the standard Macintosh order, so the standard substitute now draws each glyph by name (vendored hayro-interpret patch 76). Before, it took each code as one of its own glyph indexes, and "Low Power" drew as "@ck 'Dck Yf". Arial Narrow, Black and Unicode MS, and `CIDFontType0` fonts, are not read this way. Copying or searching those runs still needs a `/ToUnicode`. + - **2026-11-07 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing; groups left grouped (unsafe compositing, text nested past eight levels) are reported. Legacy Thai keeps the original font programs, Regular/Bold and text settings, also across same-face subsets; the narrow THSarabunPSK map repair is checked glyph by glyph against the font program; cross-family replacement is explicit. Builds on #3067 and #2811. Unsafe groups and mixed contextual styles remain partial; estimates unchanged. - **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged. - **2026-11-07 (M10, `ocr.ocr-web`, port onto #977):** Web OCR rebuilt on main's engines and preprocessing (#977). `OcrJob::run` is now `OcrJob::steps` a page at a time: the page renders, crops to the region and cleans each page, a Web Worker runs ocrs on the result, and `OcrShot::place` maps the words back through the region and preprocessing. Every strategy reads like this when only one engine is present, and the web build has only ocrs. The downloaded models are installed into the engine cache (`ocr::install`), so the Verify screen and the engine-status checks see them too. The batch keeps main's skipped-files bucket, low-confidence notes and cancel. The translations moved to craft-i18n (storytold/craft-i18n#141). The Verify screen starts the model download itself and runs once the models land; before, on a fresh page it failed with the desktop's "set PDFCRAFT_MODELS" message. Known limits: the cleaning steps, and the Verify screen's Run OCR and re-reads, run on the page. Estimates unchanged. diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index c64f9e1c0f..86b31bafd3 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -421,6 +421,12 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> { let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour"); } + pdfcraft_render::RenderWarning::Type3WidthsMissing => { + let _ = writeln!( + std::io::stderr().lock(), + "warning: page {page}: a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give" + ); + } } } Ok(()) diff --git a/crates/automation/src/bates.rs b/crates/automation/src/bates.rs index 6493df9968..71ae6b3b91 100644 --- a/crates/automation/src/bates.rs +++ b/crates/automation/src/bates.rs @@ -415,7 +415,11 @@ mod tests { let acl = Command::new("icacls.exe").arg(dir.path()).arg("/deny").arg(format!("*{sid}:(AD)")).status().unwrap(); assert!(acl.success()); - assert!(matches!(tempfile::tempdir_in(dir.path()), Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied)); + // Where the deny entry does not bind the test's own account (it did not on CI's Windows image), + // there is no denied folder to probe. + if !matches!(tempfile::tempdir_in(dir.path()), Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied) { + return; + } let direct_probe = dir.path().join("direct-probe"); let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&direct_probe).unwrap(); drop(file); diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index 51e1d54f65..6cd02115a8 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -296,7 +296,7 @@ pub(crate) fn info(d: &Document) -> Value { // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the // ones the spec names, set and cleared with doc_set_info. "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), - "creator": i.creator, "producer": i.producer, "created": i.created, "modified": i.modified, + "creator": i.creator, "producer": i.producer, "tagged": i.tagged, "has_javascript": i.has_javascript, // Where the document opens (/OpenAction, named destinations resolved): 1-based page and diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 9212b20ce6..04ab8dace4 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -410,6 +410,7 @@ fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str match warning { RenderWarning::ContentTruncated => "content past the page's safety budget was skipped", RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour", + RenderWarning::Type3WidthsMissing => "a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give", } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index fc7c0b7b20..63a09c3949 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1361,7 +1361,7 @@ pub fn tools() -> Vec { t("print_readiness", "Print readiness", "Verification only. Reports effective image resolution where each image is drawn (not against the page size), page boxes and bleed past the trim, output intents, fonts that are not embedded, and the PDF/A-2b structural result. Does not modify the document, embed fonts, invent a press profile, or check PDF/X.") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), - t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") + t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption, the file header, the file identifier). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 9e9b6a8764..7f7f32fedc 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -30,7 +30,7 @@ pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char, }; -pub use writer::{SaveOptions, civil_from_days, pdf_date, reachable_objects, serialize, write_full, write_incremental}; +pub use writer::{SaveOptions, civil_from_days, new_file_id, pdf_date, reachable_objects, serialize, write_full, write_incremental}; #[derive(Debug, thiserror::Error, Clone, PartialEq)] pub enum CosError { diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 0d6ccba7d2..56731f7e39 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -789,13 +789,21 @@ fn stamp_mod_date(doc: &mut Document, opts: &SaveOptions) { } } -/// Keep an existing `/ID`, or create one (two equal 16-byte strings, §14.4). +/// Keep any existing two-element `/ID` array as it is, even a malformed one such as `[<> <>]` or `[1 2]` (an ordinary +/// save preserves what it doesn't understand; Save as PDF/A replaces or completes it), or create one with +/// [`new_file_id`] when there is none or it isn't a two-element array. fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { if matches!(trailer.get(b"ID"), Some(Object::Array(a)) if a.len() == 2) { return; } + trailer.set(b"ID".to_vec(), new_file_id(content, opts.id_seed)); +} + +/// A new file identifier for a file whose bytes are `content` (ISO 32000-1 14.4): an array of +/// two equal 16-byte strings, the same for the same content and `seed`. +pub fn new_file_id(content: &[u8], seed: u64) -> Object { // FNV-1a over a sample of the content and the seed: stable, dependency-free. - let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ opts.id_seed; + let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ seed; for b in content.iter().step_by((content.len() / 4096).max(1)).chain(content.iter().rev().take(64)) { h ^= *b as u64; h = h.wrapping_mul(0x0100_0000_01b3); @@ -804,7 +812,7 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { id.extend_from_slice(&h.to_be_bytes()); id.extend_from_slice(&h.rotate_left(29).wrapping_mul(0x9E37_79B9_7F4A_7C15).to_be_bytes()); let s = Object::String(PdfString { bytes: id, hex: true }); - trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s])); + Object::Array(vec![s.clone(), s]) } /// Give a rewritten file a new identifier (§14.4), so that a redacted or sanitized copy does not diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index f7d7265684..728b9e3615 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -763,9 +763,8 @@ pub(crate) fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render:: } /// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read -/// the way the full inspection reads them: NULs and spaces trimmed, empty is none, dates shown as -/// [`pdfcraft_render::pretty_date`] shows them. -pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 8] { +/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. +pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); [ (&mut info.title, read("Title")), @@ -774,8 +773,6 @@ pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Documen (&mut info.keywords, read("Keywords")), (&mut info.creator, read("Creator")), (&mut info.producer, read("Producer")), - (&mut info.created, read("CreationDate").map(|d| pdfcraft_render::pretty_date(&d))), - (&mut info.modified, read("ModDate").map(|d| pdfcraft_render::pretty_date(&d))), ] } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1bf1b847c3..cff0ec010b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -11870,6 +11870,25 @@ fn imported_bookmarks_undo_in_one_step() { assert!(s.get(id).unwrap().info.outline.is_empty()); } +/// PDF/A-2 6.1.2 through the ordinary save: Save as PDF/A on a file without a binary comment, or +/// with a 2.0 header, saves a file whose header verifies. +#[test] +fn save_as_pdfa_saves_a_compliant_file_header() { + let plain = fixture(1); + assert!(plain.starts_with(b"%PDF-1.7\n1 0 obj"), "the fixture has no binary comment"); + let mut newer = plain.clone(); + newer.splice(5..8, *b"2.0"); + for bytes in [plain, newer] { + let mut s = Session::new(); + let id = s.open("x.pdf", None, Arc::new(bytes), None).unwrap(); + s.apply(id, Edit::ConvertPdfA { level: pdfcraft_preflight::Level::A2b }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.starts_with(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"), "{:?}", String::from_utf8_lossy(&saved[..20])); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!(pdfcraft_preflight::verify(&reopened, pdfcraft_preflight::Level::A2b).iter().all(|i| i.clause != "6.1.2")); + } +} + /// ISO 32000-2 §7.6.6, Table 25: a document whose only encryption is for its embedded files, /// authorised when one is opened (`/AuthEvent /EFOpen`), opens without a password, lists its /// attachment, and saves with the attachment still encrypted. diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 6373d6427f..978b9d11ae 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1402,8 +1402,9 @@ fn glyph_component(part: &str) -> Option { if let Ok(i) = crate::encodings::NAMES.binary_search_by(|(n, _)| (*n).cmp(part)) { return char::from_u32(crate::encodings::NAMES[i].1).map(String::from); } - // `from_str_radix` accepts a leading `+`, which is not a glyph name, so the digits are checked. - let hexish = |h: &str| !h.is_empty() && h.bytes().all(|b| b.is_ascii_hexdigit()); + // `from_str_radix` accepts a leading `+` and lowercase digits, and the glyph list + // specification's digits are uppercase only (`uni20ac` names nothing), so they are checked. + let hexish = |h: &str| !h.is_empty() && h.bytes().all(|b| matches!(b, b'0'..=b'9' | b'A'..=b'F')); if let Some(hex) = part.strip_prefix("uni").filter(|h| h.len() >= 4 && h.len() % 4 == 0 && hexish(h)) { let mut s = String::new(); for i in (0..hex.len()).step_by(4) { @@ -1464,6 +1465,9 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { out.encoding.insert(code as u32, c.to_string()); } } + if base_name.is_none() && !symbolic_builtin { + out.encoding.extend(symbolic_mac_roman_texts(doc, font)); + } if let Some(Object::Dict(d)) = enc.as_deref() && let Some(diffs) = d.get(b"Differences").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()) { @@ -1527,6 +1531,74 @@ fn identity_to_unicode() -> Vec { cmap.into_bytes() } +/// What each code of an embedded TrueType font that isn't flagged non-symbolic, names no base +/// encoding and doesn't name its glyphs shows when two things say the code is Mac OS Roman: its +/// Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4: +/// single bytes "look up the associated glyph descriptions" there, and a (3, 0) subtable maps "the +/// single-byte codes in the (1, 0) subtable" prepended with 0xF0), and the glyph sits where the +/// standard Macintosh glyph order (the 258 names of a version 1.0 `post` table) puts that Mac OS +/// Roman character. So code 0x88 drawn with glyph 106, `agrave`, is `à`, where Standard has +/// nothing and WinAnsi `ˆ`, while a Windows subset in the same order whose codes are WinAnsi (0x92 +/// drawn with glyph 183, `quoteright`) keeps its reading. The glyph drawn is found the way the +/// renderer finds it: the Windows (3, 0) and (3, 1) subtables first, with the code and the code at +/// 0xF000, 0xF100 and 0xF200, then the Macintosh and Unicode-platform ones. The renderer's text +/// layer reads the same codes the same way (vendored hayro-interpret patch (101)). +fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { + use skrifa::GlyphId; + use skrifa::raw::{TableProvider as _, tables::cmap::PlatformId, tables::post::DEFAULT_GLYPH_NAMES, types::Tag}; + let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() }; + // Flag bit 6, Nonsymbolic (ISO 32000-2 Table 121). + if descriptor_flags(doc, &descriptor) & 32 != 0 { + return Vec::new(); + } + let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() }; + let object = doc.resolve(object); + let Object::Stream(stream) = object.as_ref() else { return Vec::new() }; + let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() }; + let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() }; + // A CFF program in `/FontFile2` selects its glyphs by name, not through the `cmap`. A `post` + // table of version 1.0 or 2.0 (also with its 16-bit halves swapped, as the renderer reads it) + // names the glyphs, and a font that names them needn't keep the order: a Wingdings subset + // draws code 0x83 with glyph 101, `Eacute` in the order, which it names `threesans`. + let named = face + .table_data(Tag::new(b"post")) + .is_some_and(|data| matches!(data.as_bytes().get(..4), Some([0, 1, 0, 0] | [0, 2, 0, 0] | [0, 0, 0, 1] | [0, 0, 0, 2]))); + if face.cff().is_ok() || named { + return Vec::new(); + } + let Ok(cmap) = face.cmap() else { return Vec::new() }; + let records = cmap.encoding_records(); + let lookup = |code: u32, windows: bool| { + records.iter().find_map(|record| { + let offsets: &[u32] = match (record.platform_id(), record.encoding_id()) { + (PlatformId::Windows, 0 | 1) if windows => &[0, 0xF000, 0xF100, 0xF200], + (PlatformId::Macintosh | PlatformId::Unicode, 0) if !windows => &[0], + _ => return None, + }; + let subtable = record.subtable(cmap.offset_data()).ok()?; + offsets.iter().find_map(|o| subtable.map_codepoint(code + o).filter(|g| *g != GlyphId::NOTDEF)) + }) + }; + (0..=255u32) + .filter_map(|code| { + let drawn = lookup(code, true).or_else(|| lookup(code, false))?; + let mac = records.iter().find_map(|record| { + (record.platform_id() == PlatformId::Macintosh && record.encoding_id() == 0) + .then(|| record.subtable(cmap.offset_data()).ok()?.map_codepoint(code)) + .flatten() + })?; + if mac != drawn { + return None; + } + let c = char::from_u32(*crate::encodings::MAC_ROMAN.get(usize::try_from(code).ok()?)?).filter(|c| *c != '\0')?; + let standard = *DEFAULT_GLYPH_NAMES.get(usize::try_from(drawn.to_u32()).ok()?)?; + // Mac OS Roman 0xCA reads as a space, for the reason WinAnsi 0xA0 does (`MAC_ROMAN`). + let at_its_place = glyph_unicode(standard) == Some(c) || (code == 0xCA && standard == "nonbreakingspace"); + at_its_place.then(|| (code, c.to_string())) + }) + .collect() +} + #[cfg(test)] mod tests; @@ -1560,6 +1632,21 @@ mod encoding_tests { Object::Dict(enc) } + /// The glyph list specification's hex forms: `uni` and groups of exactly four uppercase + /// digits, `u` and four to six. + #[test] + fn glyph_names_take_uppercase_hex_in_whole_groups() { + assert_eq!(glyph_text("uni20ac"), None, "the specification's hex digits are uppercase"); + assert_eq!(glyph_text("u1f600"), None); + assert_eq!(glyph_text("uni65"), None, "`uni` takes groups of exactly four digits"); + assert_eq!(glyph_text("u65"), None, "`u` takes four to six digits"); + assert_eq!(glyph_text("u0010FFFF"), None); + assert_eq!(glyph_text("uni0065").as_deref(), Some("e")); + assert_eq!(glyph_text("u0065").as_deref(), Some("e"), "`u` and four digits"); + assert_eq!(glyph_text("u1F600").as_deref(), Some("\u{1f600}"), "`u` and five"); + assert_eq!(glyph_text("u10FFFF").as_deref(), Some("\u{10ffff}"), "`u` and six, the last code point"); + } + #[test] fn glyph_names_follow_the_naming_algorithm() { // Annex D names and the two hex forms, as before. diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index d1b96fe3d0..9a0a32be6a 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -1185,6 +1185,145 @@ fn low_codes_resolve_to_the_covering_range() { assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); } +/// [`cmap_only_sfnt_named`] without a `post` table. +fn cmap_only_sfnt(mac: Option<&[(u8, u8)]>, windows: &[(u16, u16)]) -> Vec { + cmap_only_sfnt_named(mac, windows, false) +} + +/// An sfnt holding a `cmap` table with these subtables: (1, 0) format 0 mapping codes to glyphs, +/// and (3, 0) format 4 mapping each code point to its glyph, one segment each; and, if `named`, a +/// version 2.0 `post` table. +fn cmap_only_sfnt_named(mac: Option<&[(u8, u8)]>, windows: &[(u16, u16)], named: bool) -> Vec { + let be16 = |out: &mut Vec, n: u16| out.extend_from_slice(&n.to_be_bytes()); + let mut subtables: Vec<((u16, u16), Vec)> = Vec::new(); + if let Some(map) = mac { + let mut t = Vec::new(); + for n in [0u16, 262, 0] { + be16(&mut t, n); + } + let mut ids = [0u8; 256]; + for &(code, glyph) in map { + ids[usize::from(code)] = glyph; + } + t.extend(ids); + subtables.push(((1, 0), t)); + } + let segments: Vec<(u16, u16)> = windows.iter().copied().chain([(0xFFFF, 0)]).collect(); + let count = segments.len() as u16; + let mut t = Vec::new(); + for n in [4u16, 16 + 8 * count, 0, 2 * count, 2, 0, 0] { + be16(&mut t, n); + } + segments.iter().for_each(|&(c, _)| be16(&mut t, c)); + be16(&mut t, 0); + segments.iter().for_each(|&(c, _)| be16(&mut t, c)); + segments.iter().for_each(|&(c, g)| be16(&mut t, if c == 0xFFFF { 1 } else { g.wrapping_sub(c) })); + segments.iter().for_each(|_| be16(&mut t, 0)); + subtables.push(((3, 0), t)); + + let mut cmap = Vec::new(); + be16(&mut cmap, 0); + be16(&mut cmap, subtables.len() as u16); + let mut offset = 4 + 8 * subtables.len() as u32; + for ((platform, encoding), bytes) in &subtables { + be16(&mut cmap, *platform); + be16(&mut cmap, *encoding); + cmap.extend_from_slice(&offset.to_be_bytes()); + offset += bytes.len() as u32; + } + subtables.iter().for_each(|(_, bytes)| cmap.extend(bytes)); + let mut tables = vec![(*b"cmap", cmap)]; + if named { + // Version 2.0 with no glyphs to name. + let mut post = vec![0, 2, 0, 0]; + post.resize(34, 0); + tables.push((*b"post", post)); + } + let mut out = vec![0, 1, 0, 0]; + out.extend_from_slice(&(tables.len() as u16).to_be_bytes()); + out.extend([0; 6]); + let mut offset = 12 + 16 * tables.len() as u32; + for (tag, table) in &tables { + out.extend(tag); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&offset.to_be_bytes()); + out.extend_from_slice(&(table.len() as u32).to_be_bytes()); + offset += table.len() as u32; + } + tables.into_iter().for_each(|(_, table)| out.extend(table)); + out +} + +/// A symbolic TrueType font with no `/Encoding` reads a code as Mac OS Roman when its (1, 0) `cmap` +/// subtable selects the glyph the code draws and the glyph sits where the standard Macintosh glyph +/// order puts that character, as the renderer's text layer reads it (vendored hayro-interpret +/// patch (101), ISO 32000-2 §9.6.5.4). Otherwise the codes read as before (Standard): WinAnsi codes +/// in that order (0x92 drawn with `quoteright`), no (1, 0) subtable, one selecting other glyphs +/// than the (3, 0) one, a font flagged non-symbolic, or one that names its glyphs. +#[test] +fn a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman() { + let read = |program: Vec, flags: i64, codes: [u32; 2]| { + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &program))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(flags)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + let u = unicode_map(&doc, &f, false); + codes.map(|c| u.text(c).unwrap_or_default().into_owned()) + }; + // `agrave` and `eacute` are glyphs 106 and 112 of the standard Macintosh order, `endash` and + // `quoteright` 178 and 183. + let mac_order = cmap_only_sfnt(Some(&[(0x88, 106), (0x8E, 112)]), &[(0xF088, 106), (0xF08E, 112)]); + assert_eq!(read(mac_order.clone(), 4, [0x88, 0x8E]), ["à", "é"]); + assert_eq!(read(mac_order, 32, [0x88, 0x8E]), ["", ""], "flagged non-symbolic"); + let winansi = cmap_only_sfnt(Some(&[(0x92, 183), (0x96, 178)]), &[(0xF092, 183), (0xF096, 178)]); + assert_eq!(read(winansi, 4, [0x92, 0x96]), ["", ""], "WinAnsi codes in the Macintosh order"); + let windows_only = cmap_only_sfnt(None, &[(0xF088, 106), (0xF08E, 112)]); + assert_eq!(read(windows_only, 4, [0x88, 0x8E]), ["", ""], "no (1, 0) subtable"); + let disagreeing = cmap_only_sfnt(Some(&[(0x88, 112), (0x8E, 106)]), &[(0xF088, 106), (0xF08E, 112)]); + assert_eq!(read(disagreeing, 4, [0x88, 0x8E]), ["", ""], "a (1, 0) subtable selecting other glyphs"); + // A `post` table of version 2.0 names the glyphs; what it calls them doesn't matter here. + let named = cmap_only_sfnt_named(Some(&[(0x88, 106), (0x8E, 112)]), &[(0xF088, 106), (0xF08E, 112)], true); + assert_eq!(read(named, 4, [0x88, 0x8E]), ["", ""], "a font that names its glyphs"); +} + +/// The Mac OS Roman reading applies only to a font that names no base encoding and isn't Symbol or +/// ZapfDingbats (ISO 32000-2 §9.6.5.4, §9.6.6.1): a named `/BaseEncoding` or `/Encoding` keeps its +/// own reading, a `/Differences` without a base encoding still gets it, and a Symbol font keeps no +/// implicit mapping. +#[test] +fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() { + let read = |entries: Vec<(&str, Object)>| { + let mut doc = Document::new_empty(); + let program = cmap_only_sfnt(Some(&[(0x88, 106), (0x8E, 112)]), &[(0xF088, 106), (0xF08E, 112)]); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &program))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut all = vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]; + all.extend(entries); + let f = font(&mut doc, all); + let u = unicode_map(&doc, &f, false); + [0x88, 0x8E].map(|c| u.text(c).unwrap_or_default().into_owned()) + }; + assert_eq!(read(vec![]), ["à", "é"]); + assert_eq!(read(vec![("Encoding", Object::name("WinAnsiEncoding"))]), ["ˆ", "Ž"], "a named base encoding"); + let mut based = Dict::new(); + based.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding")); + assert_eq!(read(vec![("Encoding", Object::Dict(based))]), ["ˆ", "Ž"], "a /BaseEncoding"); + let mut diffs = Dict::new(); + diffs.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(0x8E), Object::name("ecircumflex")])); + assert_eq!(read(vec![("Encoding", Object::Dict(diffs))]), ["à", "ê"], "/Differences without a base encoding"); + assert_eq!(read(vec![("BaseFont", Object::name("Symbol"))]), ["", ""], "Symbol"); + assert_eq!(read(vec![("BaseFont", Object::name("ZapfDingbats"))]), ["", ""], "ZapfDingbats"); + // A name it can't read clears the Mac OS Roman reading for its code, as it clears a base + // encoding's. + let mut notdef = Dict::new(); + notdef.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(0x8E), Object::name(".notdef")])); + assert_eq!(read(vec![("Encoding", Object::Dict(notdef))]), ["à", ""], "/Differences naming .notdef"); +} + #[test] fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; diff --git a/crates/preflight/src/gstate.rs b/crates/preflight/src/gstate.rs new file mode 100644 index 0000000000..64c19de116 --- /dev/null +++ b/crates/preflight/src/gstate.rs @@ -0,0 +1,235 @@ +//! PDF/A-2 6.2.5, the extended graphics state: device-dependent settings a conforming file may not +//! rely on. +//! +//! - no transfer function: no `/TR` entry, and `/TR2` only as `/Default` (ISO 32000-1 10.4); +//! - no `/HTP` (halftone phase) entry, a PDF 1.2 key ISO 32000-1 no longer defines; +//! - halftones of type 1 or 5 only, without a `/HalftoneName` (ISO 32000-1 10.5.5); the halftones +//! a type 5 halftone holds for its colorants are checked the same way; +//! - a halftone's `/TransferFunction` only where one is needed: a type 5 halftone's component for +//! a non-primary colorant has one, and no other halftone does. The primary colour components +//! are those of the standard device spaces: Gray; Red, Green and Blue; Cyan, Magenta, Yellow +//! and Black (ISO 32000-1 10.5.5.6; ISO 32000-2 10.6.5.6). The `Default` component is left as +//! it is (ISO 32000-1 Table 130); +//! - a rendering intent (`/RI`) that is one of the four of ISO 32000-1 8.6.5.8, Table 70. +//! +//! Graphics state dictionaries are found as the `/ExtGState` entries of resource dictionaries +//! (`/Resources` of pages, forms, Type 3 fonts, patterns and annotation appearances, and an +//! interactive form's `/DR`), and as the `/ExtGState` of a shading pattern (ISO 32000-1 8.7.4.3, +//! Table 76). They are looked for in what the document reaches from its trailer, following +//! references, but not into private application data (`/PieceInfo`), so an object only that data +//! refers to isn't read. A map shared by reference is read once. + +use std::collections::{BTreeSet, HashSet, VecDeque}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +/// How deep a direct object is followed: as deep as the parser nests them. +const MAX_DEPTH: u8 = 128; +/// The rendering intents of ISO 32000-1 Table 70. +const INTENTS: [&[u8]; 4] = [b"AbsoluteColorimetric", b"RelativeColorimetric", b"Saturation", b"Perceptual"]; +/// The entries of a type 5 halftone that aren't colorant halftones. +const HALFTONE_KEYS: [&[u8]; 3] = [b"Type", b"HalftoneType", b"HalftoneName"]; +/// The primary colour components of DeviceGray, DeviceRGB and DeviceCMYK, whose halftones take +/// the graphics state's transfer function. +const PRIMARY: [&[u8]; 8] = [b"Gray", b"Red", b"Green", b"Blue", b"Cyan", b"Magenta", b"Yellow", b"Black"]; +/// The entries that hold resource dictionaries. +const RESOURCES: [&[u8]; 2] = [b"Resources", b"DR"]; + +/// What the graphics states of a document break of 6.2.5. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Findings { + /// A graphics state has a `/TR` entry. + pub(crate) transfer: bool, + /// A graphics state has a `/TR2` other than `/Default`. + pub(crate) transfer2: bool, + /// A graphics state has an `/HTP`. + pub(crate) phase: bool, + /// Halftone types other than 1 and 5, in order. + pub(crate) halftone_types: BTreeSet, + /// A halftone's `/HalftoneType` is missing or isn't an integer. + pub(crate) untyped: bool, + /// A halftone has a `/HalftoneName`. + pub(crate) halftone_name: bool, + /// A halftone that may not have a `/TransferFunction` has one. + pub(crate) halftone_transfer: bool, + /// Non-primary colorants whose halftone has no `/TransferFunction`, in name order. + pub(crate) missing_transfer: BTreeSet, + /// Rendering intents outside Table 70, in name order. + pub(crate) intents: BTreeSet, + /// A rendering intent isn't a name. + pub(crate) intent_not_name: bool, +} + +/// The 6.2.5 findings over what `doc` reaches from its trailer. +pub(crate) fn check(doc: &Document) -> Findings { + scan(doc).findings +} + +/// How many objects [`check`] reads. +#[cfg(test)] +pub(crate) fn reached(doc: &Document) -> usize { + scan(doc).reached.len() +} + +fn scan(doc: &Document) -> Scan<'_> { + let mut scan = Scan { doc, read: HashSet::new(), reached: HashSet::new(), queue: VecDeque::new(), findings: Findings::default() }; + scan.walk(&Object::Dict(doc.trailer().clone()), 0); + // References are followed from a queue, not by recursion: a chain of them can be as long as + // the file. + while let Some(r) = scan.queue.pop_front() { + let o = doc.get(r); + scan.walk(&o, 0); + } + scan +} + +struct Scan<'a> { + doc: &'a Document, + /// Resource dictionaries, `/ExtGState` maps and graphics states read so far, by object number. + read: HashSet, + /// Object numbers reached so far, queued or walked. Both sets go by number, as + /// [`Document::get`] does: references differing only in generation reach the same object. + reached: HashSet, + queue: VecDeque, + findings: Findings, +} + +impl Scan<'_> { + fn walk(&mut self, o: &Object, depth: u8) { + if depth > MAX_DEPTH { + return; + } + let d = match o { + Object::Ref(r) => { + if self.reached.insert(r.num) { + self.queue.push_back(*r); + } + return; + } + Object::Array(a) => { + for v in a { + self.walk(v, depth.saturating_add(1)); + } + return; + } + Object::Dict(d) => d, + Object::Stream(s) => &s.dict, + _ => return, + }; + self.holder(d); + for (k, v) in d.iter() { + if k.as_slice() != b"PieceInfo" { + self.walk(v, depth.saturating_add(1)); + } + } + } + + /// Whether `o` is a reference read before; marks it read. + fn read_before(&mut self, o: &Object) -> bool { + matches!(o, Object::Ref(r) if !self.read.insert(r.num)) + } + + /// A dictionary that holds graphics states: through its resources, or as a shading pattern. + fn holder(&mut self, d: &Dict) { + for key in RESOURCES { + if let Some(resources) = d.get(key) { + self.resources(resources); + } + } + if d.get(b"PatternType").is_some() + && let Some(gs) = d.get(b"ExtGState") + && !self.read_before(gs) + && let Some(gs) = self.doc.resolve(gs).as_dict() + { + self.gstate(gs); + } + } + + fn resources(&mut self, resources: &Object) { + if self.read_before(resources) { + return; + } + let resources = self.doc.resolve(resources); + let Some(states) = resources.as_dict().and_then(|r| r.get(b"ExtGState")) else { return }; + if self.read_before(states) { + return; + } + let states = self.doc.resolve(states); + let Some(states) = states.as_dict() else { return }; + for (_, gs) in states.iter() { + if !self.read_before(gs) + && let Some(gs) = self.doc.resolve(gs).as_dict() + { + self.gstate(gs); + } + } + } + + /// `key` of `d`, resolved, unless it is missing or null (ISO 32000-1 7.3.9). + fn present(&self, d: &Dict, key: &[u8]) -> Option> { + d.get(key).map(|v| self.doc.resolve(v)).filter(|v| !matches!(**v, Object::Null)) + } + + fn gstate(&mut self, gs: &Dict) { + if self.present(gs, b"TR").is_some() { + self.findings.transfer = true; + } + if self.present(gs, b"TR2").is_some_and(|f| f.as_name() != Some(b"Default")) { + self.findings.transfer2 = true; + } + if self.present(gs, b"HTP").is_some() { + self.findings.phase = true; + } + if let Some(ht) = self.present(gs, b"HT") + && let Some(ht) = ht.as_dict() + { + self.halftone(ht, None); + } + if let Some(ri) = self.present(gs, b"RI") { + match ri.as_name() { + Some(ri) if !INTENTS.contains(&ri) => { + self.findings.intents.insert(String::from_utf8_lossy(ri).into_owned()); + } + Some(_) => {} + None => self.findings.intent_not_name = true, + } + } + } + + /// A halftone dictionary or stream: the graphics state's own (`colorant` is `None`), or the + /// one a type 5 halftone holds for `colorant`. + fn halftone(&mut self, ht: &Dict, colorant: Option<&[u8]>) { + // An integer (ISO 32000-1 Table 128), not a real of the same value. + let kind = self.present(ht, b"HalftoneType").and_then(|t| if let Object::Int(t) = *t { Some(t) } else { None }); + match kind { + None => self.findings.untyped = true, + Some(1 | 5) => {} + Some(kind) => { + self.findings.halftone_types.insert(kind); + } + } + if self.present(ht, b"HalftoneName").is_some() { + self.findings.halftone_name = true; + } + let transfer = self.present(ht, b"TransferFunction").is_some(); + match colorant { + Some(b"Default") => {} + Some(c) if !PRIMARY.contains(&c) => { + if !transfer { + self.findings.missing_transfer.insert(String::from_utf8_lossy(c).into_owned()); + } + } + _ => self.findings.halftone_transfer |= transfer, + } + if colorant.is_none() && kind == Some(5) { + for (k, v) in ht.iter() { + if HALFTONE_KEYS.contains(&k.as_slice()) { + continue; + } + if let Some(component) = self.doc.resolve(v).as_dict() { + self.halftone(component, Some(k)); + } + } + } + } +} diff --git a/crates/preflight/src/header.rs b/crates/preflight/src/header.rs new file mode 100644 index 0000000000..3e9263ef28 --- /dev/null +++ b/crates/preflight/src/header.rs @@ -0,0 +1,71 @@ +//! PDF/A-2 6.1.2, the file header: the file starts at its first byte with `%PDF-1.n`, `n` from 0 +//! to 7, and an end-of-line marker; the next line is a comment, `%` and at least four bytes each +//! above 127, which marks the file as binary (ISO 32000-1 7.5.2). +//! +//! A document whose next save rewrites it in full is judged by the header that save writes: the +//! version it keeps, followed by the writer's own binary comment. + +use pdfcraft_cos::Document; + +/// What the header of `doc`, as it will be saved, breaks of 6.1.2. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Header { + /// The first line isn't `%PDF-1.0` to `%PDF-1.7` at the start of the file. + pub(crate) version: bool, + /// The second line isn't a comment of at least four bytes above 127. + pub(crate) comment: bool, +} + +/// What the header of `doc` breaks of 6.1.2: as stored, or, when the next save rewrites the +/// file, as that save will write it. +pub(crate) fn check(doc: &Document) -> Header { + if doc.full_save_required() || doc.encryption_changed() { + let written = doc.version().get(..3).unwrap_or_default(); + return Header { version: !allowed(written.as_bytes()), comment: false }; + } + let data = doc.bytes(); + let Some(rest) = data.strip_prefix(b"%PDF-") else { return Header { version: true, comment: false } }; + let version = rest.get(..3).unwrap_or_default(); + let Some(rest) = rest.get(3..).and_then(eol) else { + return Header { version: true, comment: !binary_comment(after_line(rest)) }; + }; + Header { version: !allowed(version), comment: !binary_comment(rest) } +} + +/// Whether a version is `1.0` to `1.7`. +fn allowed(version: &[u8]) -> bool { + matches!(version, [b'1', b'.', b'0'..=b'7']) +} + +/// What follows an end-of-line marker (CR, LF or CR LF) at the start of `data`. +fn eol(data: &[u8]) -> Option<&[u8]> { + match data { + [b'\r', b'\n', rest @ ..] | [b'\r', rest @ ..] | [b'\n', rest @ ..] => Some(rest), + _ => None, + } +} + +/// What follows the first end-of-line marker in `data`, or nothing. +fn after_line(data: &[u8]) -> &[u8] { + let end = data.iter().position(|&b| b == b'\r' || b == b'\n').unwrap_or(data.len()); + data.get(end..).and_then(eol).unwrap_or_default() +} + +/// Whether `data` starts with `%` and four bytes above 127. +fn binary_comment(data: &[u8]) -> bool { + matches!(data, [b'%', a, b, c, d, ..] if [a, b, c, d].iter().all(|&&x| x > 127)) +} + +/// Makes the next save of `doc` write a header 6.1.2 allows, when the current one breaks it: +/// a full save, at version 1.7 when the version kept isn't 1.0 to 1.7. What it fixed, if anything. +pub(crate) fn rewrite(doc: &mut Document) -> Option { + let header = check(doc); + if !header.version && !header.comment { + return None; + } + if !allowed(doc.version().as_bytes().get(..3).unwrap_or_default()) { + doc.set_version("1.7"); + } + doc.require_full_save(); + Some("Rewrote the file header".into()) +} diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1cbe9a6ed3..d5a6d80b04 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -10,7 +10,9 @@ //! - every font embedded; //! - annotations: allowed types, printable, not hidden, with appearance streams; //! - actions: none of the forbidden kinds (JavaScript, Launch, Sound, Movie, ResetForm, -//! ImportData, Hide, SetOCGState, Rendition, Trans, GoTo3DView); no additional actions on the +//! ImportData, Hide, SetOCGState, Rendition, Trans, GoTo3DView, and the PDF 1.2 SetState and +//! NOP that ISO 32000-1 no longer defines); Named actions only for NextPage, PrevPage, +//! FirstPage and LastPage (ISO 32000-1 12.6.4.11, Table 212); no additional actions on the //! catalog, pages, fields or widgets; `NeedAppearances` not true; //! - images: no `Interpolate`, no `Alternates`, no OPI; no PostScript XObjects; //! - embedded files: PDF/A-2 only allows embedded PDF/A files; PDF/A-3 needs `AFRelationship`. @@ -21,8 +23,15 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod gstate; +mod header; pub mod icc; +mod limits; +mod spot; +mod trailer; +mod transparency; mod xmp; +mod xmp_extension; mod xmp_types; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -153,14 +162,46 @@ pub const WORDINGS: &[&str] = &[ "The title in the metadata and the document information differ", "The XMP property {property} in metadata stream {object} should have the value type {type}", "The XMP property {property} in metadata stream {object}, written {written}, should have the value type {type}", + "The XMP extension schema {schema} in metadata stream {object} has no {entry} entry", + "The XMP extension schema {schema} in metadata stream {object} describes {item} without a {entry} entry", + "The XMP extension schema {schema} in metadata stream {object} has an empty {entry} entry", + "The XMP extension schema {schema} in metadata stream {object} describes {item} with an empty {entry} entry", + "The XMP extension schema container in metadata stream {object} holds no rdf:Bag", + "The XMP extension schema container in metadata stream {object} holds {found}, not one rdf:Bag", + "Metadata stream {object} writes the namespace {namespace} without a prefix, not with {prefix}", + "Metadata stream {object} writes the namespace {namespace} with the prefix {written}, not {prefix}", // The document as a whole. + "The file doesn't start with a %PDF-1.0 to %PDF-1.7 header line", + "The file header isn't followed by a comment of four bytes above 127", + "The trailer has no file identifier (ID)", + "The file identifier (ID) in the trailer isn't two non-empty strings", "The document is encrypted", + "An integer is outside the 32-bit range", + "A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸", + "A CMap maps a character code to a CID above 65535", + "A string is longer than 32767 bytes", + "A name is longer than 127 bytes", + "Page {} has a boundary smaller than 3 or larger than 14400 units", "Device colour is used, but there is no PDF/A output intent", + "Page {} has transparency but no device-independent blending colour space or PDF/A output intent", + "A graphics state uses the blend mode {}, which ISO 32000-1 doesn't define", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile", "DeviceRGB is used, but the PDF/A output intent isn't an RGB profile", + "Separation colour spaces named {} differ in their alternate space or tint transform", + "The spot colour {} of a DeviceN colour space isn't in its Colorants dictionary", "The document has additional actions (/AA)", "The document has document-level JavaScript", + "A graphics state has a transfer function (TR)", + "A graphics state has a transfer function (TR2) other than Default", + "A graphics state has a halftone phase (HTP)", + "A halftone has type {}: only types 1 and 5 are allowed", + "A halftone's HalftoneType is missing or isn't an integer", + "A halftone has a HalftoneName", + "A halftone has a transfer function, which only a non-primary colorant's halftone may have", + "The halftone for the colorant {} has no transfer function", + "A graphics state has the rendering intent {}, which isn't a standard one", + "A graphics state has a rendering intent that isn't a name", "The form asks viewers to make appearances (NeedAppearances)", // Pages, actions, annotations and fields. "Page {} has additional actions", @@ -192,8 +233,23 @@ pub struct Declared { pub output_intents: Vec, } -const FORBIDDEN_ACTIONS: &[&[u8]] = - &[b"JavaScript", b"Launch", b"Sound", b"Movie", b"ResetForm", b"ImportData", b"Hide", b"SetOCGState", b"Rendition", b"Trans", b"GoTo3DView"]; +const FORBIDDEN_ACTIONS: &[&[u8]] = &[ + b"JavaScript", + b"Launch", + b"Sound", + b"Movie", + b"ResetForm", + b"ImportData", + b"Hide", + b"SetOCGState", + b"Rendition", + b"Trans", + b"GoTo3DView", + b"SetState", + b"NOP", +]; +/// The named actions of ISO 32000-1 Table 212, the only ones a Named action may name. +const NAMED_ACTIONS: &[&[u8]] = &[b"NextPage", b"PrevPage", b"FirstPage", b"LastPage"]; const FORBIDDEN_ANNOTS: &[&[u8]] = &[b"Sound", b"Movie", b"Screen", b"3D", b"RichMedia"]; fn catalog(doc: &Document) -> Dict { @@ -250,6 +306,19 @@ fn dict_of(o: &Object) -> Option<&Dict> { } } +/// Whether the document has a PDF/A output intent whose `/DestOutputProfile` is a stream: one with +/// a null or missing profile describes no colour, so it doesn't stand in for a blending colour +/// space (6.2.10). +fn pdfa_intent_has_profile(doc: &Document) -> bool { + let cat = catalog(doc); + let list = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + list.iter().any(|oi| { + let oi = doc.resolve(oi); + let Some(d) = oi.as_dict() else { return false }; + d.name(b"S") == Some(b"GTS_PDFA1") && d.get(b"DestOutputProfile").is_some_and(|p| matches!(&*doc.resolve(p), Object::Stream(_))) + }) +} + fn has_pdfa_intent(doc: &Document) -> bool { pdfa_intent_profile(doc).is_some() } @@ -369,6 +438,37 @@ fn referenced_content(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) out } +/// The fonts the document draws with: those a resource dictionary names (`/Resources`, of a page or +/// a page tree node, a form XObject or an annotation's appearance stream, a pattern or a Type 3 +/// font). PDF/A asks that every font "used for rendering" be embedded (ISO 19005-2, 6.2.11.4.1). A +/// font that only the interactive form's default resources hold (`/AcroForm /DR`, ISO 32000-1 +/// 12.7.2) draws nothing, so it needn't be: an appearance stream that does draw with it names it in +/// its own `/Resources`. +fn drawing_fonts(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + for (_, o) in objs { + let Some(resources) = dict_of(o).and_then(|d| d.get(b"Resources")).map(|r| doc.resolve(r)) else { continue }; + let Some(fonts) = resources.as_dict().and_then(|r| r.get(b"Font")).map(|f| doc.resolve(f)) else { continue }; + if let Some(fonts) = fonts.as_dict() { + out.extend(fonts.iter().filter_map(|(_, f)| f.as_ref()).map(|r| font_object(doc, r))); + } + } + out +} + +/// How many references [`font_object`] follows, as the document's own `resolve` does. +const MAX_REFERENCE_HOPS: usize = 32; + +/// The object a font entry's reference `r` ends at: an object can be nothing but a reference to +/// another (ISO 32000-1 7.3.10), and the font dictionary is the last one in that chain. +fn font_object(doc: &Document, mut r: ObjRef) -> ObjRef { + for _ in 0..MAX_REFERENCE_HOPS { + let Object::Ref(next) = &*doc.get(r) else { break }; + r = *next; + } + r +} + /// Whether a stream's own dictionary says it holds content: a form XObject, or a stream with a /// `/BBox` and no `/Subtype` (a tiling pattern, or an appearance stream that leaves out /// `/Subtype /Form`). Mesh shadings may carry a `/BBox` too, but their data isn't content. @@ -486,7 +586,48 @@ pub fn verify(doc: &Document, level: Level) -> Vec { let message = fill(pattern, None, &values); push(Issue { clause, message, pattern, value: None, values, page: None, object: Some(object), fixable }); }; - let cat = catalog(doc); + check_identification(doc, level, &mut issue); + let objs = objects(doc); + check_xmp_packets(&objs, &located_issue); + check_output_intent(doc, &objs, &mut issue); + check_catalog_actions(doc, &mut issue); + // Pages: annotations and page actions. + let pages = pdfcraft_model::pages(doc); + // Each annotation's page: the first page that lists it. + let mut annot_pages: std::collections::HashMap = std::collections::HashMap::new(); + for (i, p) in pages.iter().enumerate() { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + #[cfg(test)] + ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len())); + for r in a.iter().filter_map(Object::as_ref) { + annot_pages.entry(r).or_insert(i); + } + } + } + let page_of = |r: ObjRef| annot_pages.get(&r).copied(); + for (i, p) in pages.iter().enumerate() { + if p.dict.get(b"AA").is_some() { + issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); + } + } + check_transparency(doc, &pages, &mut issue); + let drawing = drawing_fonts(doc, &objs); + check_objects(doc, level, &objs, &drawing, &page_of, &mut issue); + check_limits(doc, &objs, &pages, &mut issue); + check_spots(doc, &objs, &mut issue); + check_graphics_states(doc, &mut issue); + // One message per kind of problem is enough for repeated ones (as preflight summaries do). + let mut out = out.into_inner(); + let mut seen = std::collections::HashSet::new(); + out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); + out +} + +/// 6.1.2, 6.1.3 and 6.6: identification and metadata, the file header, the file identifier and +/// encryption. +fn check_identification(doc: &Document, level: Level, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { // 6.1.3 / 6.6: identification and metadata. match metadata(doc) { None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true), @@ -503,16 +644,37 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } } } - // 6.1.3: encryption. + // 6.1.2: the file header. + let header = header::check(doc); + if header.version { + issue("6.1.2", "The file doesn't start with a %PDF-1.0 to %PDF-1.7 header line", None, None, true); + } + if header.comment { + issue("6.1.2", "The file header isn't followed by a comment of four bytes above 127", None, None, true); + } + // 6.1.3: the file identifier and encryption. + match trailer::check(doc) { + Some(trailer::IdProblem::Missing) => issue("6.1.3", "The trailer has no file identifier (ID)", None, None, true), + Some(trailer::IdProblem::Malformed) => { + issue("6.1.3", "The file identifier (ID) in the trailer isn't two non-empty strings", None, None, true) + } + None => {} + } if doc.trailer().get(b"Encrypt").is_some() { issue("6.1.3", "The document is encrypted", None, None, true); } - // 6.2.2: output intent when device colour is used. - let objs = objects(doc); - // 6.6.2.3.1: the value types of predefined XMP properties, in every metadata stream. - let mut mistyped = Vec::new(); - xmp_types::for_each_packet(&objs, |object, packet| { +} + +/// 6.6.2.3.1 and 6.6.2.3.3: predefined XMP value types and extension schema descriptions, in +/// every metadata stream. +fn check_xmp_packets( + objs: &[(ObjRef, std::sync::Arc)], + located_issue: &impl Fn(&'static str, &'static str, Vec<(&'static str, String)>, ObjRef, bool), +) { + let (mut mistyped, mut extension) = (Vec::new(), xmp_extension::Findings::default()); + xmp_types::for_each_packet(objs, |object, packet| { mistyped.extend(xmp_types::mistyped(packet).into_iter().map(|((name, written), ty)| (object, name, written, ty))); + extension.add(object, packet); }); for (object, name, written, ty) in mistyped { let mut values = vec![("{property}", name.clone()), ("{object}", object.to_string()), ("{type}", ty)]; @@ -535,7 +697,104 @@ pub fn verify(doc: &Document, level: Level) -> Vec { ); } } - let used = uses_device_colour(doc, &objs); + report_xmp_extension(extension, located_issue); +} + +/// 6.6.2.3.3: what the extension schema descriptions of the metadata streams lack. +fn report_xmp_extension( + extension: xmp_extension::Findings, + located_issue: &impl Fn(&'static str, &'static str, Vec<(&'static str, String)>, ObjRef, bool), +) { + for m in extension.missing { + let mut values = vec![("{schema}", m.schema), ("{object}", m.object.to_string()), ("{entry}", m.entry)]; + match (m.item, m.empty) { + (None, false) => located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} has no {entry} entry", + values, + m.object, + false, + ), + (None, true) => located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} has an empty {entry} entry", + values, + m.object, + false, + ), + (Some(item), false) => { + values.push(("{item}", item)); + located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} describes {item} without a {entry} entry", + values, + m.object, + false, + ); + } + (Some(item), true) => { + values.push(("{item}", item)); + located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} describes {item} with an empty {entry} entry", + values, + m.object, + false, + ); + } + } + } + for c in extension.not_bag { + let object = c.object; + if c.found.is_empty() { + located_issue( + "6.6.2.3.3", + "The XMP extension schema container in metadata stream {object} holds no rdf:Bag", + vec![("{object}", object.to_string())], + object, + false, + ); + } else { + let values = vec![("{object}", object.to_string()), ("{found}", c.found)]; + located_issue( + "6.6.2.3.3", + "The XMP extension schema container in metadata stream {object} holds {found}, not one rdf:Bag", + values, + object, + false, + ); + } + } + for p in extension.prefixes { + let mut values = vec![("{object}", p.object.to_string()), ("{namespace}", p.namespace.to_owned()), ("{prefix}", p.own.to_owned())]; + if p.written.is_empty() { + located_issue( + "6.6.2.3.3", + "Metadata stream {object} writes the namespace {namespace} without a prefix, not with {prefix}", + values, + p.object, + false, + ); + } else { + values.push(("{written}", p.written)); + located_issue( + "6.6.2.3.3", + "Metadata stream {object} writes the namespace {namespace} with the prefix {written}, not {prefix}", + values, + p.object, + false, + ); + } + } +} + +/// 6.2.3 and 6.2.4.3: the output intent device colour needs. +fn check_output_intent( + doc: &Document, + objs: &[(ObjRef, std::sync::Arc)], + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let used = uses_device_colour(doc, objs); match pdfa_intent_profile(doc) { None if used.any() => { issue("6.2.3", "Device colour is used, but there is no PDF/A output intent", None, None, !used.cmyk); @@ -554,7 +813,11 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } None => {} } +} + +fn check_catalog_actions(doc: &Document, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { // 6.1.3 / 6.6.1: catalog actions. + let cat = catalog(doc); if cat.get(b"AA").is_some() { issue("6.5.2", "The document has additional actions (/AA)", None, None, true); } @@ -568,61 +831,137 @@ pub fn verify(doc: &Document, level: Level) -> Vec { { issue("6.4.1", "The form asks viewers to make appearances (NeedAppearances)", None, None, true); } - // Pages: annotations and page actions. - let pages = pdfcraft_model::pages(doc); - // Each annotation's page: the first page that lists it. - let mut annot_pages: std::collections::HashMap = std::collections::HashMap::new(); +} + +fn check_transparency( + doc: &Document, + pages: &[pdfcraft_model::Page], + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + // 6.2.10: blend modes are those ISO 32000-1 defines, and without a PDF/A output intent (one with + // a profile) a page with transparency names a blending colour space. + let intent = pdfa_intent_has_profile(doc); + // Pages share resources, forms and fonts: what one page found under them, the next takes as is. + let mut shared = transparency::Shared::default(); for (i, p) in pages.iter().enumerate() { - if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) - && let Some(a) = a.as_array() - { - #[cfg(test)] - ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len())); - for r in a.iter().filter_map(Object::as_ref) { - annot_pages.entry(r).or_insert(i); - } + let painting = transparency::page_painting(doc, &p.dict, &mut shared); + for name in painting.unknown_blend_modes { + issue("6.2.10", "A graphics state uses the blend mode {}, which ISO 32000-1 doesn't define", Some(name), Some(i), false); + } + if painting.transparency && !intent && !transparency::page_names_blending_space(doc, &p.dict) { + let n = Some((i + 1).to_string()); + issue("6.2.10", "Page {} has transparency but no device-independent blending colour space or PDF/A output intent", n, Some(i), false); } } - let page_of = |r: ObjRef| annot_pages.get(&r).copied(); +} + +fn check_limits( + doc: &Document, + objs: &[(ObjRef, std::sync::Arc)], + pages: &[pdfcraft_model::Page], + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + // 6.1.13: the implementation limits of ISO 32000-1 Annex C. + let over = limits::exceeded(doc, objs, &referenced_content(doc, objs)); + if over.int { + issue("6.1.13", "An integer is outside the 32-bit range", None, None, false); + } + if over.real { + issue("6.1.13", "A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸", None, None, false); + } + if over.string { + issue("6.1.13", "A string is longer than 32767 bytes", None, None, false); + } + if limits::cid_over_limit(doc, objs) { + issue("6.1.13", "A CMap maps a character code to a CID above 65535", None, None, false); + } + if over.name { + issue("6.1.13", "A name is longer than 127 bytes", None, None, false); + } for (i, p) in pages.iter().enumerate() { - if p.dict.get(b"AA").is_some() { - issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); + if limits::page_box_out_of_range(doc, &p.dict) { + issue("6.1.13", "Page {} has a boundary smaller than 3 or larger than 14400 units", Some((i + 1).to_string()), Some(i), false); } } - check_objects(doc, level, &objs, &page_of, &mut issue); - // One message per kind of problem is enough for repeated ones (as preflight summaries do). - let mut out = out.into_inner(); - let mut seen = std::collections::HashSet::new(); - out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); - out +} + +fn check_spots( + doc: &Document, + objs: &[(ObjRef, std::sync::Arc)], + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + // 6.2.4.4: Separation and DeviceN colour spaces. + let spots = spot::check(doc, objs); + for name in spots.differing { + issue("6.2.4.4", "Separation colour spaces named {} differ in their alternate space or tint transform", Some(name), None, false); + } + for name in spots.undefined { + issue("6.2.4.4", "The spot colour {} of a DeviceN colour space isn't in its Colorants dictionary", Some(name), None, false); + } +} + +fn check_graphics_states(doc: &Document, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + // 6.2.5: the extended graphics state. + let gs = gstate::check(doc); + if gs.transfer { + issue("6.2.5", "A graphics state has a transfer function (TR)", None, None, false); + } + if gs.transfer2 { + issue("6.2.5", "A graphics state has a transfer function (TR2) other than Default", None, None, false); + } + if gs.phase { + issue("6.2.5", "A graphics state has a halftone phase (HTP)", None, None, false); + } + for kind in gs.halftone_types { + issue("6.2.5", "A halftone has type {}: only types 1 and 5 are allowed", Some(kind.to_string()), None, false); + } + if gs.untyped { + issue("6.2.5", "A halftone's HalftoneType is missing or isn't an integer", None, None, false); + } + if gs.halftone_name { + issue("6.2.5", "A halftone has a HalftoneName", None, None, false); + } + if gs.halftone_transfer { + issue("6.2.5", "A halftone has a transfer function, which only a non-primary colorant's halftone may have", None, None, false); + } + for colorant in gs.missing_transfer { + issue("6.2.5", "The halftone for the colorant {} has no transfer function", Some(colorant), None, false); + } + for intent in gs.intents { + issue("6.2.5", "A graphics state has the rendering intent {}, which isn't a standard one", Some(intent), None, false); + } + if gs.intent_not_name { + issue("6.2.5", "A graphics state has a rendering intent that isn't a name", None, None, false); + } } fn check_objects( doc: &Document, level: Level, objects: &[(ObjRef, std::sync::Arc)], + drawing: &std::collections::HashSet, page_of: &impl Fn(ObjRef) -> Option, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), ) { for (r, o) in objects { let Some(d) = dict_of(o) else { continue }; - check_one_object(doc, level, *r, d, o, page_of, issue); + check_one_object(doc, level, d, o, drawing.contains(r), page_of(*r), issue); } } fn check_one_object( doc: &Document, level: Level, - reference: ObjRef, dict: &Dict, object: &Object, - page_of: &impl Fn(ObjRef) -> Option, + draws: bool, + page: Option, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), ) { let ty = dict.name(b"Type"); let subtype = dict.name(b"Subtype"); check_actions(doc, dict, issue); - if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page_of(reference), issue) { + if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page, issue) { // A forbidden annotation stops every later check for its object, as the scan did before // it was split into helpers: the rest of its own checks and the form field, font, stream // and embedded file below all came after that `continue`. @@ -631,7 +970,8 @@ fn check_one_object( if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { issue("6.5.2", "A form field has additional actions", None, None, true); } - if ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some() { + // A Type 0 font's descendant is checked through it. + if draws && (ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some()) { check_font(doc, dict, issue); } if let Object::Stream(stream) = object { @@ -829,10 +1169,8 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec if *read > MAX_ACTION_CHAIN { return Some("too deeply chained".into()); } - if let Some(s) = d.name(b"S") - && FORBIDDEN_ACTIONS.contains(&s) - { - return Some(String::from_utf8_lossy(s).into_owned()); + if let Some(s) = action_forbidden(doc, d) { + return Some(s); } match d.get(b"Next") { Some(Object::Dict(next)) => stack.push(next), @@ -849,6 +1187,24 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec None } +/// The forbidden kind of the action `d` itself, if it is one: a forbidden type, or a Named +/// action for anything but the four of [`NAMED_ACTIONS`]. The type and the name may each be given +/// directly or by reference. +fn action_forbidden(doc: &Document, d: &Dict) -> Option { + let s = doc.resolve(d.get(b"S")?); + let s = s.as_name()?; + if FORBIDDEN_ACTIONS.contains(&s) { + return Some(String::from_utf8_lossy(s).into_owned()); + } + let n = d.get(b"N").map(|n| doc.resolve(n)); + match n.as_deref().and_then(Object::as_name) { + _ if s != b"Named" => None, + Some(n) if NAMED_ACTIONS.contains(&n) => None, + Some(n) => Some(format!("Named {}", String::from_utf8_lossy(n))), + None => Some("Named".into()), + } +} + /// Whether `o` is (or refers to) a forbidden action or one chaining to it, or to nothing. fn forbidden(doc: &Document, o: &Object) -> bool { match &*doc.resolve(o) { @@ -874,6 +1230,9 @@ pub fn convert(doc: &mut Document, level: Level) -> Result bool { + self.int && self.real && self.string && self.name + } + + fn name(&mut self, n: &[u8]) { + self.name |= n.len() > MAX_NAME; + } + + /// One value and, within [`MAX_DEPTH`], what it holds: a dictionary's keys count as names. + fn value(&mut self, o: &Object, depth: u8) { + if self.all() || depth > MAX_DEPTH { + return; + } + match o { + Object::Int(i) => self.int |= !(MIN_INT..=MAX_INT).contains(i), + Object::Real(r) => self.real |= !r.is_finite() || r.abs() > MAX_REAL || (*r != 0.0 && r.abs() < MIN_REAL), + Object::String(s) => self.string |= s.bytes.len() > MAX_STRING, + Object::Name(n) => self.name(n), + Object::Array(a) => { + for v in a { + self.value(v, depth.saturating_add(1)); + } + } + Object::Dict(d) => self.dict(d, depth), + Object::Stream(s) => self.dict(&s.dict, depth), + Object::Null | Object::Bool(_) | Object::Ref(_) => {} + } + } + + /// The operands of one content stream, inline images' dictionaries included. + fn content(&mut self, data: &[u8]) { + for op in pdfcraft_content::ops(data) { + if self.all() { + return; + } + for v in &op.operands { + self.value(v, 0); + } + if let Some((image, _)) = &op.inline { + self.dict(image, 0); + } + } + } + + fn dict(&mut self, d: &Dict, depth: u8) { + for (k, v) in d.iter() { + self.name(k); + self.value(v, depth.saturating_add(1)); + } + } +} + +/// The limits that the trailer, the indirect objects `objs` and the content streams go past. +/// `content` holds the content streams only a reference identifies (a page's `/Contents`, Type 3 +/// glyph procedures), besides form XObjects and appearance streams, which say so themselves. +pub(crate) fn exceeded(doc: &Document, objs: &[(ObjRef, std::sync::Arc)], content: &HashSet) -> Exceeded { + let mut found = Exceeded::default(); + found.dict(doc.trailer(), 0); + for (_, o) in objs { + if found.all() { + return found; + } + found.value(o, 0); + } + let mut budget = DecodeBudget::new(MAX_CONTENT_BYTES, 0); + let mut read: HashSet = HashSet::new(); + for page in pdfcraft_model::pages(doc) { + if found.all() || budget.remaining() == 0 { + return found; + } + let refs = contents(doc, &page.dict); + let mut joined = Vec::new(); + for r in &refs { + if let Object::Stream(s) = &*doc.get(*r) + && let Ok(data) = budget.decode(s) + { + joined.extend_from_slice(&data); + joined.push(b' '); + } + } + read.extend(refs); + found.content(&joined); + } + for (r, o) in objs { + if found.all() || budget.remaining() == 0 { + break; + } + if let Object::Stream(s) = &**o + && !read.contains(r) + && (content.contains(r) || crate::is_content(&s.dict)) + && let Ok(data) = budget.decode(s) + { + found.content(&data); + } + } + found +} + +/// A page's `/Contents` streams, in order. +fn contents(doc: &Document, page: &Dict) -> Vec { + match page.get(b"Contents") { + Some(Object::Ref(r)) => match &*doc.get(*r) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => vec![*r], + }, + Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(), + _ => Vec::new(), + } +} + +/// Whether one of a page's boundaries (with the media and crop boxes it inherits) is narrower or +/// lower than 3 units, or wider or higher than 14400. +pub(crate) fn page_box_out_of_range(doc: &Document, page: &Dict) -> bool { + BOXES.iter().filter_map(|k| page.get(k)).any(|b| { + let b = doc.resolve(b); + let v: Vec = b.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); + let [x0, y0, x1, y1] = v.as_slice() else { return false }; + let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs()); + // A size past `f64` (corners near ±1e308) is as much too large as any other. + [w, h].iter().any(|s| !(MIN_PAGE..=MAX_PAGE).contains(s)) + }) +} + +/// Whether an embedded CMap of a Type 0 font among `objs`, or one it uses through `/UseCMap`, maps +/// a code to a CID above 65535: a `begincidrange` entry ` cid` reaches +/// `cid + high − low`, a `begincidchar` entry ` cid` reaches `cid` (ISO 32000-1 9.7.5.4). A +/// CMap named rather than embedded is a predefined one, within the limit. The CMaps are decoded +/// within one allowance for the document. +pub(crate) fn cid_over_limit(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> bool { + let mut budget = DecodeBudget::new(MAX_CMAP_BYTES, 0); + let mut read: HashSet = HashSet::new(); + for (_, o) in objs { + let Some(d) = o.as_dict() else { continue }; + if d.name(b"Subtype") != Some(b"Type0") { + continue; + } + let mut next = d.get(b"Encoding").and_then(Object::as_ref); + for _ in 0..MAX_CMAP_CHAIN { + let Some(r) = next.filter(|r| read.insert(*r)) else { break }; + if budget.remaining() == 0 { + return false; + } + let Object::Stream(cmap) = &*doc.get(r) else { break }; + if budget.decode(cmap).is_ok_and(|data| maps_past_limit(&data)) { + return true; + } + next = cmap.dict.get(b"UseCMap").and_then(Object::as_ref); + } + } + false +} + +/// Whether the CMap program `data` maps a code to a CID above [`MAX_CID`]. The entries of a +/// `begincidrange` … `endcidrange` block are the operands of its `endcidrange`, read as content is. +fn maps_past_limit(data: &[u8]) -> bool { + let value = |o: &Object| -> Option { + match o { + Object::String(s) if s.bytes.len() <= 4 => Some(s.bytes.iter().fold(0u64, |v, b| (v << 8) | u64::from(*b))), + Object::Int(i) => u64::try_from(*i).ok(), + _ => None, + } + }; + pdfcraft_content::ops(data).any(|op| match op.op.as_slice() { + b"endcidrange" => op.operands.as_chunks::<3>().0.iter().any(|[low, high, cid]| match (value(low), value(high), value(cid)) { + (Some(low), Some(high), Some(cid)) => cid.saturating_add(high.saturating_sub(low)) > MAX_CID, + _ => false, + }), + b"endcidchar" => op.operands.as_chunks::<2>().0.iter().any(|[_, cid]| value(cid).is_some_and(|cid| cid > MAX_CID)), + _ => false, + }) +} diff --git a/crates/preflight/src/spot.rs b/crates/preflight/src/spot.rs new file mode 100644 index 0000000000..8367d8d28a --- /dev/null +++ b/crates/preflight/src/spot.rs @@ -0,0 +1,212 @@ +//! PDF/A-2 6.2.4.4, Separation and DeviceN colour spaces: +//! +//! - Separation spaces of the same colorant name agree on their alternate space and tint +//! transform, also where a DeviceN space's `/Colorants` dictionary holds them. They are compared +//! as objects, whether stored directly or by reference, at any depth (ISO 32000-1 7.3.10); +//! - a DeviceN space names each spot colorant it uses in the `/Colorants` dictionary of its +//! attributes (ISO 32000-1 8.6.6.5, Table 71); a null or missing entry defines nothing. The +//! process colorants (`Cyan`, `Magenta`, `Yellow`, `Black`, and those an NChannel space's +//! `/Process` lists) and `None` are not spot colorants. +//! +//! Colour spaces are found wherever they are written: in resource dictionaries, images, shadings +//! and other colour spaces, direct or by reference. + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; + +/// How deep a direct object is followed (arrays and dictionaries inside one another): as deep as +/// the parser nests them. +const MAX_DEPTH: u8 = 128; +/// How deep two definitions are compared, references followed. +const MAX_COMPARE_DEPTH: u8 = 32; +/// How many object pairs the comparisons of one document visit in all. Past it, a comparison +/// claims no difference. +const MAX_COMPARE_STEPS: u32 = 1 << 20; +/// The decoded bytes the stream comparisons of one document may spend in all. +const MAX_COMPARE_BYTES: usize = 64 << 20; +/// The stream entries that say how the data is stored, not what it is. +const ENCODING: [&[u8]; 3] = [b"Length", b"Filter", b"DecodeParms"]; +/// The colorants of DeviceCMYK, which a DeviceN space may name without defining them. +const PROCESS: [&[u8]; 4] = [b"Cyan", b"Magenta", b"Yellow", b"Black"]; + +/// What the colour spaces of a document break of 6.2.4.4. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Findings { + /// Colorant names whose Separation spaces differ, in name order. + pub(crate) differing: BTreeSet, + /// Spot colorants a DeviceN space uses without a `/Colorants` entry, in name order. + pub(crate) undefined: BTreeSet, +} + +/// The 6.2.4.4 findings over the indirect objects `objs`. +pub(crate) fn check(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> Findings { + let compare = Compare { doc, equal: HashSet::new(), steps: MAX_COMPARE_STEPS, budget: DecodeBudget::new(MAX_COMPARE_BYTES, 0) }; + let mut scan = Scan { doc, compare, separations: HashMap::new(), findings: Findings::default() }; + for (_, o) in objs { + scan.walk(o, 0); + } + scan.findings +} + +struct Scan<'a> { + doc: &'a Document, + compare: Compare<'a>, + /// Each colorant name's first Separation space: its alternate space and tint transform. + separations: HashMap, (Object, Object)>, + findings: Findings, +} + +impl Scan<'_> { + fn walk(&mut self, o: &Object, depth: u8) { + if depth > MAX_DEPTH { + return; + } + match o { + Object::Array(a) => { + match a.first().and_then(Object::as_name) { + Some(b"Separation") => self.separation(a), + Some(b"DeviceN") => self.devicen(a), + _ => {} + } + for v in a { + self.walk(v, depth.saturating_add(1)); + } + } + Object::Dict(d) => { + for (_, v) in d.iter() { + self.walk(v, depth.saturating_add(1)); + } + } + Object::Stream(s) => { + for (_, v) in s.dict.iter() { + self.walk(v, depth.saturating_add(1)); + } + } + _ => {} + } + } + + /// `[/Separation name alternateSpace tintTransform]`: the same name must keep the same + /// alternate space and tint transform. + fn separation(&mut self, a: &[Object]) { + let [_, name, alternate, tint, ..] = a else { return }; + let name = self.doc.resolve(name); + let Some(name) = name.as_name() else { return }; + let this = (alternate.clone(), tint.clone()); + match self.separations.get(name) { + Some((alt, fun)) => { + if !self.compare.same(alt, &this.0, 0) || !self.compare.same(fun, &this.1, 0) { + self.findings.differing.insert(String::from_utf8_lossy(name).into_owned()); + } + } + None => { + self.separations.insert(name.to_vec(), this); + } + } + } + + /// `[/DeviceN names alternateSpace tintTransform attributes]`: each spot colorant in `names` + /// needs an entry in the attributes' `/Colorants`. + fn devicen(&mut self, a: &[Object]) { + let Some(names) = a.get(1).map(|n| self.doc.resolve(n)) else { return }; + let Some(names) = names.as_array() else { return }; + let attributes = a.get(4).map(|d| self.doc.resolve(d)); + let attributes = attributes.as_deref().and_then(Object::as_dict); + let colorants = attributes.and_then(|d| d.get(b"Colorants")).map(|c| self.doc.resolve(c)); + let colorants = colorants.as_deref().and_then(Object::as_dict); + let nchannel = attributes.and_then(|d| d.get(b"Subtype")).map(|t| self.doc.resolve(t)).is_some_and(|t| t.as_name() == Some(b"NChannel")); + let process = attributes + .filter(|_| nchannel) + .and_then(|d| d.get(b"Process")) + .map(|p| self.doc.resolve(p)) + .and_then(|p| p.as_dict().and_then(|p| p.get(b"Components")).map(|c| self.doc.resolve(c))); + let process: Vec> = process + .as_deref() + .and_then(Object::as_array) + .map(|c| c.iter().filter_map(|n| self.doc.resolve(n).as_name().map(<[u8]>::to_vec)).collect()) + .unwrap_or_default(); + for n in names.iter().map(|n| self.doc.resolve(n)) { + let Some(n) = n.as_name() else { continue }; + let spot = n != b"None" && !PROCESS.contains(&n) && !process.iter().any(|p| p == n); + // Each entry is a Separation space for the colorant it is keyed by (ISO 32000-1 8.6.6.5, + // Table 71); a null entry, or one referring to no object, defines nothing (7.3.9). + let defined = colorants.and_then(|c| c.get(n)).is_some_and(|e| self.separation_named(e, n)); + if spot && !defined { + self.findings.undefined.insert(String::from_utf8_lossy(n).into_owned()); + } + } + } + + /// Whether `o` is `[/Separation name ...]` for the colorant `name`. + fn separation_named(&self, o: &Object, name: &[u8]) -> bool { + let o = self.doc.resolve(o); + let Some([kind, n, ..]) = o.as_array().map(Vec::as_slice) else { return false }; + kind.as_name() == Some(b"Separation") && self.doc.resolve(n).as_name() == Some(name) + } +} + +/// Compares definitions, remembering which reference pairs it found equal, so a structure shared +/// by many definitions is compared once, within [`MAX_COMPARE_STEPS`] and [`MAX_COMPARE_BYTES`]. +struct Compare<'a> { + doc: &'a Document, + /// Reference pairs compared equal. A depth cut-off only ever answers "different", so an + /// equal answer holds at any depth. + equal: HashSet<(ObjRef, ObjRef)>, + steps: u32, + budget: DecodeBudget, +} + +impl Compare<'_> { + /// Whether `a` and `b` are the same object, wherever their parts are stored: references are + /// followed (to [`MAX_COMPARE_DEPTH`]), so two identical streams under different object + /// numbers are the same, and streams compare by their decoded data. Once the steps or the + /// decode allowance run out, nothing more is called different. + fn same(&mut self, a: &Object, b: &Object, depth: u8) -> bool { + let pair = match (a, b) { + (Object::Ref(x), Object::Ref(y)) if x == y || self.equal.contains(&(*x, *y)) => return true, + (Object::Ref(x), Object::Ref(y)) => Some((*x, *y)), + _ => None, + }; + let Some(steps) = self.steps.checked_sub(1) else { return true }; + self.steps = steps; + if depth > MAX_COMPARE_DEPTH { + return false; + } + let (a, b) = (self.doc.resolve(a), self.doc.resolve(b)); + let deeper = depth.saturating_add(1); + let same = match (&*a, &*b) { + (Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| self.same(p, q, deeper)), + (Object::Dict(x), Object::Dict(y)) => self.same_dict(x, y, &[], deeper), + (Object::Stream(x), Object::Stream(y)) => self.same_data(x, y, deeper) && self.same_dict(&x.dict, &y.dict, &ENCODING, deeper), + (x, y) => x == y, + }; + if same && let Some(pair) = pair { + self.equal.insert(pair); + } + same + } + + /// Whether two dictionaries hold the same entries, those keyed in `skip` aside. + fn same_dict(&mut self, a: &Dict, b: &Dict, skip: &[&[u8]], depth: u8) -> bool { + let kept = |d: &Dict| d.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).count(); + kept(a) == kept(b) && a.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).all(|(k, v)| b.get(k).is_some_and(|w| self.same(v, w, depth))) + } + + /// Whether two streams hold the same data, however each is encoded. Data that can't be + /// decoded within the allowance is not called different. + fn same_data(&mut self, a: &Stream, b: &Stream, depth: u8) -> bool { + let stored_alike = |c: &mut Self, k: &[u8]| match (a.dict.get(k), b.dict.get(k)) { + (None, None) => true, + (Some(x), Some(y)) => c.same(x, y, depth), + _ => false, + }; + if a.raw == b.raw && stored_alike(self, b"Filter") && stored_alike(self, b"DecodeParms") { + return true; + } + match (self.budget.decode(a), self.budget.decode(b)) { + (Ok(x), Ok(y)) => x == y, + _ => true, + } + } +} diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index e691adae84..e0107d9f21 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -1,7 +1,11 @@ use super::*; fn build(objs: &[&str], trailer_extra: &str) -> Document { - let mut out = b"%PDF-1.7\n".to_vec(); + build_with_header(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n", objs, trailer_extra) +} + +fn build_with_header(header: &[u8], objs: &[&str], trailer_extra: &str) -> Document { + let mut out = header.to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { offsets.push(out.len()); @@ -491,12 +495,279 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { } } +/// PDF/A-2 6.1.13 holds a file to the implementation limits of ISO 32000-1 Annex C (Table C.1): +/// an integer past 32 bits, a string over 32767 bytes or a name over 127 bytes (decoded: a name +/// written with `#xx` escapes counts its bytes once read), in an object or in content; and a page +/// boundary under 3 or over 14400 units, read after inheritance, so a page that overrides its +/// parent's box is judged by its own. +#[test] +fn implementation_limits_are_reported() { + let limits = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() }; + let doc = |extra: &str, content: &str, parent_box: &str, page_box: &str| { + let objects = [ + format!("<< /Type /Catalog /Pages 2 0 R {extra} >>"), + format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox {parent_box} >>"), + format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R {page_box} >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let refs: Vec<&str> = objects.iter().map(String::as_str).collect(); + build(&refs, "") + }; + let ok = "[0 0 200 200]"; + assert!(limits(&doc("", "", ok, "")).is_empty(), "{:?}", limits(&doc("", "", ok, ""))); + assert_eq!(limits(&doc("/Big 2147483648", "", ok, "")), ["An integer is outside the 32-bit range"]); + assert_eq!(limits(&doc("/Small -2147483649", "", ok, "")), ["An integer is outside the 32-bit range"]); + assert!(limits(&doc("/Edge -2147483648 /Top 2147483647", "", ok, "")).is_empty()); + assert_eq!(limits(&doc("", "2147483648 0 Td", ok, "")), ["An integer is outside the 32-bit range"]); + let long = "x".repeat(32768); + assert_eq!(limits(&doc(&format!("/S ({long})"), "", ok, "")), ["A string is longer than 32767 bytes"]); + assert_eq!(limits(&doc("", &format!("BT ({long}) Tj ET"), ok, "")), ["A string is longer than 32767 bytes"]); + assert!(limits(&doc(&format!("/S ({})", "x".repeat(32767)), "", ok, "")).is_empty()); + let name = "n".repeat(128); + assert_eq!(limits(&doc(&format!("/V /{name}"), "", ok, "")), ["A name is longer than 127 bytes"]); + assert_eq!(limits(&doc(&format!("/{name} 1"), "", ok, "")), ["A name is longer than 127 bytes"]); + assert_eq!(limits(&doc("", &format!("/{name} Do"), ok, "")), ["A name is longer than 127 bytes"]); + // 200 bytes as written, 66 once the escapes are read. + let escaped = format!("A{}", "#41".repeat(65) + &"B".repeat(4)); + assert!(limits(&doc(&format!("/V /{escaped}"), "", ok, "")).is_empty()); + assert_eq!(limits(&doc("", "", "[0 0 2 200]", "")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); + assert_eq!(limits(&doc("", "", ok, "/CropBox [0 0 14401 200]")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); + assert!(limits(&doc("", "", "[0 0 2 2]", "/MediaBox [0 0 3 14400]")).is_empty(), "the page's own box overrides its parent's"); +} + +/// 6.1.13 also reads the trailer, a page's `/Contents` streams joined (operands may end one +/// stream and their operator start the next, ISO 32000-1 7.8.2), values nested as deep as the +/// parser allows, and a page box whose size overflows to infinity. +#[test] +fn implementation_limits_reach_the_trailer_split_content_deep_values_and_huge_boxes() { + let limits = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() }; + let stream = |data: &str| { + format!( + "<< /Length {} >> +stream +{data} +endstream", + data.len() + ) + }; + let page = |contents: &str, catalog_extra: &str, media: &str, trailer: &str| { + let (first, second) = contents.split_once('|').unwrap_or((contents, "")); + build( + &[ + &format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + &format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox {media} >>"), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] >>", + &stream(first), + &stream(second), + ], + trailer, + ) + }; + let ok = "[0 0 200 200]"; + assert!(limits(&page("|", "", ok, "")).is_empty()); + assert_eq!(limits(&page("|", "", ok, "/Custom 2147483648")), ["An integer is outside the 32-bit range"]); + let long = "x".repeat(32768); + assert_eq!(limits(&page(&format!("BT ({long})|Tj ET"), "", ok, "")), ["A string is longer than 32767 bytes"]); + let deep = format!("/Deep {}2147483648{}", "[".repeat(40), "]".repeat(40)); + assert_eq!(limits(&page("|", &deep, ok, "")), ["An integer is outside the 32-bit range"]); + // Corners near ±1e308 are reals past the limit too, and the width overflows to infinity. + assert_eq!( + limits(&page("|", "", "[-1e308 0 1e308 200]", "")), + [ + "A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸", + "Page 1 has a boundary smaller than 3 or larger than 14400 units" + ] + ); +} + +/// 6.1.13's two remaining limits of ISO 32000-1 Annex C: a real number beyond ±3.403 × 10³⁸ (or +/// nonzero and nearer zero than ±1.175 × 10⁻³⁸), and +/// a CID above 65535 that an embedded CMap of a Type 0 font maps to (a `begincidrange` reaching +/// past it, a `begincidchar` naming one, or a CMap it uses through `/UseCMap`). +#[test] +fn implementation_limits_cover_reals_and_cids() { + let limits = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() }; + let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()); + let doc = |catalog_extra: &str, cmap: &str, used: &str| { + let first = stream("/Type /CMap /CMapName /Test-A /UseCMap 6 0 R", cmap); + let second = stream("/Type /CMap /CMapName /Test-B", used); + build( + &[ + &format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F 4 0 R >> >> >>", + "<< /Type /Font /Subtype /Type0 /BaseFont /Test /Encoding 5 0 R /DescendantFonts [] >>", + &first, + &second, + ], + "", + ) + }; + let range = |low: &str, high: &str, cid: u32| format!("1 begincidrange <{low}> <{high}> {cid} endcidrange"); + let near = format!("34{}.0", "0".repeat(37)); + let far = format!("4{}.0", "0".repeat(38)); + assert!(limits(&doc(&format!("/R {near} /S -{near}"), &range("0000", "00ff", 65280), "")).is_empty(), "both at their limits"); + assert_eq!(limits(&doc(&format!("/R {far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + assert_eq!(limits(&doc(&format!("/R -{far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + // A nonzero real nearer zero than ±1.175 × 10⁻³⁸ is not allowed either; zero and the + // smallest allowed magnitude are. + let tiny = format!("0.{}1", "0".repeat(39)); + let smallest = format!("0.{}1175", "0".repeat(37)); + assert!(limits(&doc(&format!("/Z 0.0 /M -0.0 /S {smallest} /T -{smallest}"), "", "")).is_empty(), "zero and the smallest real"); + assert_eq!(limits(&doc(&format!("/R {tiny}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + assert_eq!(limits(&doc(&format!("/R -{tiny}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + let cid = ["A CMap maps a character code to a CID above 65535"]; + assert_eq!(limits(&doc("", &range("3f00", "3fff", 65536), "")), cid); + assert_eq!(limits(&doc("", &range("0000", "0100", 65280), "")), cid, "the range reaches 65536"); + assert_eq!(limits(&doc("", "1 begincidchar <01> 70000 endcidchar", "")), cid); + assert_eq!(limits(&doc("", &range("0000", "00ff", 0), &range("3f00", "3fff", 65536))), cid, "through /UseCMap"); +} + +/// PDF/A-2 6.2.4.4: Separation spaces of one colorant name agree on their alternate space and tint +/// transform, also inside a DeviceN space's `/Colorants`; and a DeviceN space names each spot +/// colorant it uses in its `/Colorants` (ISO 32000-1 8.6.6.5, Table 71). Process colorants (the +/// CMYK four, and an NChannel space's `/Process` components) and `None` need no entry. +#[test] +fn separation_and_devicen_spaces_are_checked() { + let spots = |spaces: &str| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>"); + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &page, + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>", + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 1 0] /N 1 >>", + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>", + ], + "", + ); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect() + }; + let red = |alt: &str, f: u32| format!("[/Separation /Red /{alt} {f} 0 R]"); + // The same name, the same alternate and an identical tint transform (another object): fine. + assert!(spots(&format!("/A {} /B {}", red("DeviceRGB", 4), red("DeviceRGB", 6))).is_empty()); + assert_eq!( + spots(&format!("/A {} /B {}", red("DeviceRGB", 4), red("DeviceRGB", 5))), + ["Separation colour spaces named Red differ in their alternate space or tint transform"] + ); + assert_eq!( + spots(&format!("/A {} /B {}", red("DeviceRGB", 4), red("DeviceGray", 4))), + ["Separation colour spaces named Red differ in their alternate space or tint transform"] + ); + // Inside two DeviceN spaces' Colorants. + let nchannel = |f: u32| format!("[/DeviceN [/Red] /DeviceRGB 4 0 R << /Subtype /NChannel /Colorants << /Red {} >> >>]", red("DeviceRGB", f)); + assert_eq!( + spots(&format!("/A {} /B {}", nchannel(4), nchannel(5))), + ["Separation colour spaces named Red differ in their alternate space or tint transform"] + ); + // Spot colorants need a Colorants entry; process colorants and None don't. + assert_eq!( + spots("/A [/DeviceN [/Spot1 /Cyan /None] /DeviceCMYK 4 0 R]"), + ["The spot colour Spot1 of a DeviceN colour space isn't in its Colorants dictionary"] + ); + assert_eq!( + spots("/A [/DeviceN [/Spot1] /DeviceRGB 4 0 R << /Colorants << >> >>]"), + ["The spot colour Spot1 of a DeviceN colour space isn't in its Colorants dictionary"] + ); + assert!(spots(&format!("/A [/DeviceN [/Red /Black] /DeviceRGB 4 0 R << /Colorants << /Red {} >> >>]", red("DeviceRGB", 4))).is_empty()); + assert!( + spots("/A [/DeviceN [/PrCyan /Black] /DeviceCMYK 4 0 R << /Subtype /NChannel /Process << /ColorSpace /DeviceCMYK /Components [/PrCyan /M /Y /Black] >> >>]") + .is_empty() + ); +} + +/// From the review of the 6.2.4.4 checks: nested parts compare by content, `All` and `None` get +/// no exemption, names may be given by reference, `/Process` exempts only in an NChannel space, a +/// null `/Colorants` entry defines nothing, and a colour space nested deep is still found. +#[test] +fn separation_and_devicen_checks_follow_references_depth_and_nchannel() { + let spots = |spaces: &str| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>"); + let icc = "<< /N 3 /Length 4 >>\nstream\nabcd\nendstream"; + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &page, + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>", + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 1 0] /N 1 >>", + icc, + icc, + "/Red", + "null", + ], + "", + ); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect() + }; + let differ = |n: &str| vec![format!("Separation colour spaces named {n} differ in their alternate space or tint transform")]; + let undefined = |n: &str| vec![format!("The spot colour {n} of a DeviceN colour space isn't in its Colorants dictionary")]; + // Identical ICC streams under different numbers are the same alternate space. + assert!(spots("/A [/Separation /Red [/ICCBased 6 0 R] 4 0 R] /B [/Separation /Red [/ICCBased 7 0 R] 4 0 R]").is_empty()); + assert_eq!(spots("/A [/Separation /All /DeviceRGB 4 0 R] /B [/Separation /All /DeviceRGB 5 0 R]"), differ("All")); + assert_eq!(spots("/A [/Separation 8 0 R /DeviceRGB 4 0 R] /B [/Separation /Red /DeviceRGB 5 0 R]"), differ("Red")); + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Process << /Components [/Spot] >> >>]"), undefined("Spot")); + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot null >> >>]"), undefined("Spot")); + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot 9 0 R >> >>]"), undefined("Spot")); + let deep = format!("/A {}[/DeviceN [/Spot] /DeviceRGB 4 0 R]{}", "[".repeat(40), "]".repeat(40)); + assert_eq!(spots(&deep), undefined("Spot")); +} + +/// From the second review of the 6.2.4.4 checks: a `/Colorants` entry is a Separation space of +/// its own colorant, tint-transform streams compare by decoded data, and a structure shared by +/// many definitions is compared once rather than once per path through it. +#[test] +fn separation_checks_match_colorant_entries_decode_streams_and_share_comparisons() { + // Two copies of a chain of arrays, each level naming the next twice: comparing them path by + // path takes about 2^19 steps. + const LEVELS: usize = 18; + let chain = |first: usize| -> Vec { + (0..LEVELS).map(|k| if k + 1 < LEVELS { format!("[{n} 0 R {n} 0 R]", n = first + k + 1) } else { "[1 2 3]".into() }).collect() + }; + let (a, b) = (5, 5 + LEVELS); + let spots = |spaces: &str| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>"); + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), + page, + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>".into(), + ]; + objects.extend(chain(a)); + objects.extend(chain(b)); + objects.push("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length 15 >>\nstream\n{ dup dup pop }\nendstream".into()); + objects.push("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Filter /ASCIIHexDecode /Length 31 >>\nstream\n7B206475702064757020706F70207D>\nendstream".into()); + objects.push("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length 15 >>\nstream\n{ dup pop dup }\nendstream".into()); + let objects: Vec<&str> = objects.iter().map(String::as_str).collect(); + let doc = build(&objects, ""); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect() + }; + let differ = vec!["Separation colour spaces named Red differ in their alternate space or tint transform".to_string()]; + let undefined = vec!["The spot colour Spot of a DeviceN colour space isn't in its Colorants dictionary".to_string()]; + // A Colorants entry that isn't a Separation space of the same colorant defines nothing. + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot /DeviceRGB >> >>]"), undefined); + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot [/Separation /Other /DeviceRGB 4 0 R] >> >>]"), undefined); + assert!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot [/Separation /Spot /DeviceRGB 4 0 R] >> >>]").is_empty()); + // The same PostScript function, once plain and once hex-encoded, is the same tint transform. + let (plain, hex, other) = (5 + 2 * LEVELS, 6 + 2 * LEVELS, 7 + 2 * LEVELS); + assert!(spots(&format!("/A [/Separation /Red /DeviceRGB {plain} 0 R] /B [/Separation /Red /DeviceRGB {hex} 0 R]")).is_empty()); + assert_eq!(spots(&format!("/A [/Separation /Red /DeviceRGB {plain} 0 R] /B [/Separation /Red /DeviceRGB {other} 0 R]")), differ); + // Equal copies compared again and again still leave room to find the one that differs. + let sep = |f: usize| format!("[/Separation /Red /DeviceRGB {f} 0 R]"); + assert_eq!(spots(&format!("/A {} /B {} /C {} /D {} /E {}", sep(a), sep(b), sep(b), sep(b), sep(4))), differ); +} + const XMP_NS: &str = concat!( r#"xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" "#, r#"xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" "#, r#"xmlns:stRef="http://ns.adobe.com/xap/1.0/sType/ResourceRef#" xmlns:xmpDM="http://ns.adobe.com/xmp/1.0/DynamicMedia/" "#, r#"xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" xmlns:xmpTPg="http://ns.adobe.com/xap/1.0/t/pg/" "#, - r#"xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#" xmlns:pdf="http://ns.adobe.com/pdf/1.3/""# + r#"xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" "#, + r#"xmlns:tiff="http://ns.adobe.com/tiff/1.0/" xmlns:exif="http://ns.adobe.com/exif/1.0/" xmlns:aux="http://ns.adobe.com/exif/1.0/aux/""# ); /// The mistyped predefined properties of a packet whose one description holds `body` (its @@ -720,6 +991,400 @@ fn xmp_value_types_are_reported_by_verify() { assert_eq!(found.iter().map(|i| i.object).collect::>(), [Some(ObjRef::new(5, 0))]); } +/// PDF/A-2 6.6.2.3.1 for the TIFF, EXIF and Additional EXIF schemas of the September 2005 XMP +/// Specification (pages 58–70), with the EXIF value types of pages 83–87. +#[test] +fn xmp_exif_schemas_have_their_value_types() { + // TIFF: integers, closed choices of integer, rationals, sequences, language alternatives. + assert!(element("tiff:ImageWidth", "256").is_empty()); + assert_eq!(element("tiff:ImageWidth", "256 mm"), ["tiff:ImageWidth"]); + assert_eq!(element("tiff:Orientation", "4/1"), ["tiff:Orientation"]); + assert!(element("tiff:XResolution", "72/1").is_empty()); + assert_eq!(element("tiff:XResolution", "1/2"), ["tiff:XResolution"]); + assert!(element("tiff:BitsPerSample", "88").is_empty()); + assert_eq!(element("tiff:BitsPerSample", "88.0"), ["tiff:BitsPerSample"]); + assert_eq!(element("tiff:TransferFunction", "0"), ["tiff:TransferFunction"]); + assert_eq!(element("tiff:ImageDescription", "A picture"), ["tiff:ImageDescription"]); + // EXIF: GPS coordinates (page 85). + for good in ["51,30,26N", "0,5.25W", "120,5,0E", "8,0.000001S"] { + assert!(element("exif:GPSLatitude", good).is_empty(), "{good}"); + } + for bad in ["51.5N", "51,30,26", "51,30.N", "51,30,26X", "N", "51,30,2.6N", "a,30,26N", "51,30N", "51.5,30,26N"] { + assert_eq!(element("exif:GPSLatitude", bad), ["exif:GPSLatitude"], "{bad}"); + } + assert!(element("exif:GPSTimeStamp", "2016-02-01T13:19:21").is_empty()); + assert_eq!(element("exif:ISOSpeedRatings", "100"), ["exif:ISOSpeedRatings"]); + // EXIF structures: Flash, OECF/SFR, CFAPattern, DeviceSettings (pages 85–87). + assert!(mistyped(r#">True3"#).is_empty()); + assert_eq!(mistyped(r#">yes"#), ["exif:Flash"]); + assert_eq!(mistyped(r#">"#), ["exif:Flash"]); + assert_eq!(element("exif:Flash", "fired"), ["exif:Flash"]); + assert!( + mistyped(r#">1/2"#).is_empty() + ); + assert_eq!( + mistyped(r#">0.5"#), + ["exif:OECF"] + ); + assert_eq!( + mistyped( + r#">1/2"# + ), + ["exif:CFAPattern"] + ); + assert_eq!( + mistyped(r#">2.0"#), + ["exif:DeviceSettingDescription"] + ); + assert_eq!( + mistyped(r#">a"#), + ["exif:DeviceSettingDescription"] + ); + // Additional EXIF properties (page 69). + assert!(element("aux:Lens", "50mm").is_empty()); + assert_eq!(element("aux:Lens", "50mm"), ["aux:Lens"]); + assert_eq!(element("aux:SerialNumber", "1"), ["aux:SerialNumber"]); +} + +/// The 6.6.2.3.3 findings of a packet whose extension schema bag holds `schema`, with the five +/// description namespaces bound to the prefixes in `prefixes` (`pdfaSchema` and so on by default). +fn extension(schema: &str, prefixes: [&str; 4]) -> (Vec, Vec<&'static str>) { + let found = extension_findings(schema, prefixes); + (found.missing.into_iter().map(|m| m.entry).collect(), found.prefixes.into_iter().map(|p| p.own).collect()) +} + +/// [`extension`]'s findings in full, as if from the metadata stream `3 0 R`. +fn extension_findings(schema: &str, prefixes: [&str; 4]) -> xmp_extension::Findings { + let [s, p, t, f] = prefixes; + let xmp = format!( + concat!( + r#""#, + r#""#, + r#"{schema}"#, + r#""# + ), + s = s, + p = p, + t = t, + f = f, + schema = schema + ); + let parsed = xmp::parse(&xmp).expect("parses"); + let mut found = xmp_extension::Findings::default(); + found.add(ObjRef::new(3, 0), &parsed); + found +} + +const OWN: [&str; 4] = ["pdfaSchema", "pdfaProperty", "pdfaType", "pdfaField"]; + +/// A complete schema description, with `drop` left out of it (`pdfaSchema:schema` and so on). +fn schema_without(drop: &str) -> String { + let entry = |name: &str, value: &str| if name == drop { String::new() } else { format!("<{name}>{value}") }; + let property = ["pdfaProperty:name", "pdfaProperty:valueType", "pdfaProperty:category", "pdfaProperty:description"] + .iter() + .map(|n| entry(n, "x")) + .collect::(); + let field = ["pdfaField:name", "pdfaField:valueType", "pdfaField:description"].iter().map(|n| entry(n, "x")).collect::(); + let field = entry("pdfaType:field", &format!(r#"{field}"#)); + let value_type = + ["pdfaType:type", "pdfaType:namespaceURI", "pdfaType:prefix", "pdfaType:description"].iter().map(|n| entry(n, "x")).collect::(); + [ + entry("pdfaSchema:schema", "Some schema"), + entry("pdfaSchema:namespaceURI", "http://example.com/ns/"), + entry("pdfaSchema:prefix", "ex"), + entry("pdfaSchema:property", &format!(r#"{property}"#)), + entry("pdfaSchema:valueType", &format!(r#"{value_type}{field}"#)), + ] + .concat() +} + +/// PDF/A-2 6.6.2.3.3: every extension schema description has its required entries, in each of its +/// four kinds, and writes each description namespace with its own prefix. +#[test] +fn xmp_extension_schema_descriptions_are_complete() { + assert_eq!(extension(&schema_without(""), OWN), (vec![], vec![])); + // The property and value type lists of a schema are optional. + assert_eq!(extension(&schema_without("pdfaSchema:property"), OWN), (vec![], vec![])); + assert_eq!(extension(&schema_without("pdfaSchema:valueType"), OWN), (vec![], vec![])); + // Every other entry is required. + for required in [ + "pdfaSchema:schema", + "pdfaSchema:namespaceURI", + "pdfaSchema:prefix", + "pdfaProperty:name", + "pdfaProperty:valueType", + "pdfaProperty:category", + "pdfaProperty:description", + "pdfaType:type", + "pdfaType:namespaceURI", + "pdfaType:prefix", + "pdfaType:description", + "pdfaType:field", + "pdfaField:name", + "pdfaField:valueType", + "pdfaField:description", + ] { + assert_eq!(extension(&schema_without(required), OWN), (vec![required.to_string()], vec![]), "{required}"); + } + // An entry may be an attribute of an rdf:Description item. + let as_attribute = r#""#; + assert_eq!(extension(&format!("{}{as_attribute}", schema_without("")), OWN), (vec![], vec![])); + let missing_attribute = r#""#; + assert_eq!(extension(&format!("{}{missing_attribute}", schema_without("")), OWN).0, ["pdfaSchema:namespaceURI"]); + // Each namespace with its own prefix: the right URI under another prefix breaks the rule. + for (i, own) in OWN.iter().enumerate() { + let mut prefixes = OWN; + prefixes[i] = "other"; + let schema = schema_without("").replace(&format!("{own}:"), "other:"); + assert_eq!(extension(&schema, prefixes), (vec![], vec![*own]), "{own}"); + } + // A second property list is read as well as the first. + let complete = schema_without(""); + let second = complete.replacen( + "", + r#"p"#, + 1, + ); + assert_eq!(extension(&second, OWN).0, ["pdfaProperty:valueType", "pdfaProperty:category", "pdfaProperty:description"]); + // The container is an rdf:Bag (TechNote 0009, 4.2). + let container = |inner: &str| { + let xmp = format!( + r#"{inner}"# + ); + let mut found = xmp_extension::Findings::default(); + found.add(ObjRef::new(3, 0), &xmp::parse(&xmp).expect("parses")); + found.not_bag.into_iter().map(|c| c.found).collect::>() + }; + let schema = r#"Shttp://e/e"#; + assert!(container(&format!("{schema}")).is_empty()); + // What it holds instead is named as the packet writes it. + assert_eq!(container(&format!("{schema}")), ["rdf:Seq"]); + assert_eq!(container(r#""#), ["rdf:Description"]); + assert_eq!(container(&format!("{schema}")), ["rdf:Bag, rdf:Bag"]); + assert_eq!(container(""), [""]); + // The container itself is written as pdfaExtension too (TechNote 0009, 4.1). + let xmp = concat!( + r#""#, + r#""#, + r#"Shttp://e/"#, + r#"e"# + ); + let mut found = xmp_extension::Findings::default(); + found.add(ObjRef::new(3, 0), &xmp::parse(xmp).expect("parses")); + assert!(found.missing.is_empty()); + let written: Vec<(&str, &str, &str)> = found.prefixes.iter().map(|p| (p.namespace, p.own, p.written.as_str())).collect(); + assert_eq!(written, [("http://www.aiim.org/pdfa/ns/extension/", "pdfaExtension", "ext")]); + // Through verify, under 6.6.2.3.3 and not fixable. + let xmp = concat!( + r#""#, + r#""#, + r#"Se"#, + r#""# + ); + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + let issues: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").collect(); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].message, "The XMP extension schema #1 in metadata stream 3 0 R has no pdfaSchema:namespaceURI entry"); + assert_eq!(issues[0].object, Some(ObjRef::new(3, 0))); + assert!(!issues[0].fixable); +} + +/// PDF/A-2 6.6.2.3.3: an entry that names something is incomplete when it is empty, while the +/// human-readable descriptions may be empty (TechNote 0009, 4.3 to 4.6). +#[test] +fn xmp_extension_naming_entries_are_not_empty() { + let complete = schema_without(""); + let emptied = |name: &str, value: &str, with: &str| { + let element = format!("<{name}>{value}"); + assert_eq!(complete.matches(&element).count(), 1, "{name}"); + complete.replace(&element, &format!("<{name}>{with}")) + }; + let said = |schema: &str| -> Vec<(String, bool)> { extension_findings(schema, OWN).missing.into_iter().map(|m| (m.entry, m.empty)).collect() }; + for (name, value) in [ + ("pdfaSchema:namespaceURI", "http://example.com/ns/"), + ("pdfaSchema:prefix", "ex"), + ("pdfaProperty:name", "x"), + ("pdfaProperty:valueType", "x"), + ("pdfaProperty:category", "x"), + ("pdfaType:type", "x"), + ("pdfaType:namespaceURI", "x"), + ("pdfaType:prefix", "x"), + ("pdfaField:name", "x"), + ("pdfaField:valueType", "x"), + ] { + assert_eq!(said(&emptied(name, value, "")), [(name.to_owned(), true)], "{name}"); + assert_eq!(said(&emptied(name, value, " \n\t ")), [(name.to_owned(), true)], "{name} blank"); + } + for (name, value) in + [("pdfaSchema:schema", "Some schema"), ("pdfaProperty:description", "x"), ("pdfaType:description", "x"), ("pdfaField:description", "x")] + { + assert_eq!(said(&emptied(name, value, "")), [], "{name}"); + } + // An empty element written shut, and an empty attribute, are empty too. + let shut = complete.replace("x", ""); + assert_eq!(said(&shut), [("pdfaType:prefix".to_owned(), true)]); + let attribute = r#""#; + assert_eq!(said(&format!("{complete}{attribute}")), [("pdfaSchema:prefix".to_owned(), true)]); + // A URI given as an rdf:resource, or a value given as rdf:value, is a value. + let resource = + complete.replace("x", r#""#); + assert_eq!(said(&resource), []); + let value = complete.replace("x", r#""#); + assert_eq!(said(&value), []); + // A comment is not a value. + assert_eq!(said(&emptied("pdfaType:prefix", "x", "")), [("pdfaType:prefix".to_owned(), true)]); + + // Through verify, with their own wordings. + let xmp = concat!( + r#""#, + r#""#, + r#""#, + r#"Shttp://a.example/"#, + r#""#, + r#"Thttp://t.example/"#, + r#" d"#, + r#""#, + r#""# + ); + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + let messages: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").map(|i| i.message).collect(); + assert_eq!( + messages, + [ + "The XMP extension schema http://a.example/ in metadata stream 3 0 R has an empty pdfaSchema:prefix entry", + "The XMP extension schema http://a.example/ in metadata stream 3 0 R describes T with an empty pdfaType:prefix entry", + ] + ); +} + +/// PDF/A-2 6.6.2.3.3: each incomplete description is reported by itself, named by its schema's +/// namespace URI, its own name and its metadata stream, and so is each misused prefix and container. +#[test] +fn xmp_extension_findings_say_where_they_are() { + // Two schemas lacking the same entries are two findings, each with its schema. + let schema = |uri: &str, property: &str| { + format!( + concat!( + r#"S{uri}"#, + r#"e{property}"#, + r#""# + ), + uri = uri, + property = property + ) + }; + let named = "pText"; + let two = format!(r#"{}{}"#, schema("http://a.example/", named), schema("http://b.example/", "")); + let found = extension_findings(&two, OWN); + let said: Vec<(&str, Option<&str>, &str)> = found.missing.iter().map(|m| (m.schema.as_str(), m.item.as_deref(), m.entry.as_str())).collect(); + assert_eq!( + said, + [ + ("http://a.example/", Some("p"), "pdfaProperty:category"), + ("http://a.example/", Some("p"), "pdfaProperty:description"), + ("http://b.example/", Some("#1"), "pdfaProperty:name"), + ("http://b.example/", Some("#1"), "pdfaProperty:valueType"), + ("http://b.example/", Some("#1"), "pdfaProperty:category"), + ("http://b.example/", Some("#1"), "pdfaProperty:description"), + ] + ); + assert!(found.missing.iter().all(|m| m.object == ObjRef::new(3, 0))); + // A field is named by its value type and its own name. + let field = schema_without("pdfaField:description") + .replace(">x", ">Dim") + .replace(">x", ">w"); + let found = extension_findings(&field, OWN); + assert_eq!(found.missing.iter().map(|m| m.item.as_deref()).collect::>(), [Some("Dim/w")]); + // A name the packet gives is repeated only so far. + let long = format!("http://example.com/{}", "a".repeat(400)); + let found = extension_findings(&schema(&long, ""), OWN); + assert!(found.missing[0].schema.ends_with('…') && found.missing[0].schema.chars().count() == 201, "{}", found.missing[0].schema); + // So many findings are enough. + let many = vec![schema("http://m.example/", ""); 400].join(r#""#); + assert_eq!(extension_findings(&many, OWN).missing.len(), 1000); + + // Through verify: each wording, with the stream it is in. + let verify_packet = |description: &str| -> Vec { + let xmp = format!( + concat!( + r#""#, + r#""# + ), + description + ); + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").map(|i| i.message).collect() + }; + let ns = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#""#; + assert_eq!( + verify_packet(&format!( + r#"{ns}>{}"#, + schema("http://a.example/", named) + )), + [ + "The XMP extension schema http://a.example/ in metadata stream 3 0 R describes p without a pdfaProperty:category entry", + "The XMP extension schema http://a.example/ in metadata stream 3 0 R describes p without a pdfaProperty:description entry", + ] + ); + assert_eq!( + verify_packet(&format!( + r#"{ns}>{}"#, + schema( + "http://a.example/", + &format!("{named}externald") + ) + )), + ["The XMP extension schema container in metadata stream 3 0 R holds rdf:Seq, not one rdf:Bag"] + ); + assert_eq!( + verify_packet(&format!(r#"{ns}>"#)), + ["The XMP extension schema container in metadata stream 3 0 R holds no rdf:Bag"] + ); + let other = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:s="http://www.aiim.org/pdfa/ns/schema#""#; + assert_eq!( + verify_packet(&format!( + r#"{other}>Shttp://a.example/e"# + )), + ["Metadata stream 3 0 R writes the namespace http://www.aiim.org/pdfa/ns/schema# with the prefix s, not pdfaSchema"] + ); + let default = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/""#; + assert_eq!( + verify_packet(&format!( + r#"{default}>Shttp://a.example/e"# + )), + ["Metadata stream 3 0 R writes the namespace http://www.aiim.org/pdfa/ns/schema# without a prefix, not with pdfaSchema"] + ); +} + +/// PDF/A-2 6.6.2.3.1 for the TIFF, EXIF and Additional EXIF schemas: a property is known by its +/// namespace URI, whatever prefix the packet binds to it, and is named by the namespace's +/// preferred prefix. +#[test] +fn xmp_exif_properties_are_known_by_namespace_uri() { + let tiff = r#"xmlns:foo="http://ns.adobe.com/tiff/1.0/""#; + assert_eq!(mistyped(&format!(r#">256 mm"#)), ["tiff:ImageWidth"]); + assert!(mistyped(&format!(r#">256"#)).is_empty()); + assert_eq!(mistyped(r#"xmlns:bar="http://ns.adobe.com/exif/1.0/" bar:GPSLatitude="51.5N">"#), ["exif:GPSLatitude"]); + assert_eq!( + mistyped(r#">50mm"#), + ["aux:Lens"] + ); + // The same local names in another namespace, even under the preferred prefixes, are not these + // properties. + let other = r#"xmlns:tiff="http://example.com/not-tiff/""#; + assert!(mistyped(&format!(r#">256 mm"#)).is_empty()); + assert!(mistyped(r#"xmlns:exif="http://example.com/not-exif/" exif:GPSLatitude="51.5N">"#).is_empty()); + assert!(mistyped(r#">50mm"#).is_empty()); +} + /// Holes are filled in one pass: the first `{}` from `value`, named holes from `values`, and what /// has nothing for it, a value's own braces and a brace left open stay as they are. #[test] @@ -755,6 +1420,12 @@ fn xmp_mistyped_properties_name_their_type() { [t("xmpMM:History", "seq ResourceEvent"), t("xmpTPg:MaxPageSize", "Dimensions")] ); assert_eq!(types(">p"), [t("xmpMM:Pantry", "bag struct")]); + // The TIFF and EXIF types, as the 2005 edition writes them. + assert_eq!(types(r#"tiff:XResolution="0.5" exif:GPSLatitude="N">"#), [t("exif:GPSLatitude", "GPSCoordinate"), t("tiff:XResolution", "Rational")]); + assert_eq!( + types(">8firedo"), + [t("exif:Flash", "Flash"), t("exif:OECF", "OECF/SFR"), t("tiff:BitsPerSample", "seq Integer")] + ); } /// PDF/A-2 6.6.2.3.1: a property is known by its namespace URI, whatever prefix the packet binds @@ -831,3 +1502,661 @@ fn xmp_value_forms_are_those_xmp_allows() { ["xmpMM:DerivedFrom"] ); } + +/// PDF/A asks for the fonts "used for rendering" to be embedded (ISO 19005-2, 6.2.11.4.1). A font +/// that only the form's default resources hold (`/AcroForm /DR`, ISO 32000-1 12.7.2) draws +/// nothing, as in veraPDF's 6-4-1-t02-pass-a; one that a widget's appearance stream draws with is +/// named in that stream's `/Resources`, and still has to be embedded. +#[test] +fn only_fonts_something_draws_with_must_be_embedded() { + let fonts = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.11.4").map(|i| i.message).collect() }; + let form = |appearance_fonts: &str| { + build( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 4 0 R /ZaDb 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << >> /Annots [6 0 R] >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /F 4 /Rect [10 10 90 30] /P 3 0 R /AP << /N 7 0 R >> >>", + &format!("<< /Type /XObject /Subtype /Form /BBox [0 0 80 20] /Resources << {appearance_fonts} >> /Length 0 >>\nstream\n\nendstream"), + // An object that is nothing but a reference to the Helvetica font (ISO 32000-1 7.3.10). + "4 0 R", + ], + "", + ) + }; + assert!(fonts(&form("")).is_empty(), "{:?}", fonts(&form(""))); + assert_eq!(fonts(&form("/Font << /Helv 4 0 R >>")), ["The font Helvetica isn't embedded"]); + // Named through a reference to it, it is still the font something draws with. + assert_eq!(fonts(&form("/Font << /Helv 8 0 R >>")), ["The font Helvetica isn't embedded"]); + // A page's own font is checked as before. + assert_eq!(fonts(&messy()), ["The font Helvetica isn't embedded"]); +} + +/// PDF/A-2 6.1.2: the file starts with `%PDF-1.0` to `%PDF-1.7` and an end-of-line marker, then a +/// comment of `%` and four bytes above 127; Save as PDF/A rewrites a header that doesn't. +#[test] +fn file_header_is_checked_and_rewritten() { + let objs = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"]; + let header = |h: &[u8]| -> Vec { + let doc = build_with_header(h, &objs, ""); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.2").map(|i| i.message).collect() + }; + let version = || vec!["The file doesn't start with a %PDF-1.0 to %PDF-1.7 header line".to_string()]; + let comment = || vec!["The file header isn't followed by a comment of four bytes above 127".to_string()]; + for fine in [&b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"[..], b"%PDF-1.4\r\n%\x80\x81\x82\x83af\r\n", b"%PDF-1.0\r%\xFF\xFF\xFF\xFF\r"] { + assert!(header(fine).is_empty(), "{}", String::from_utf8_lossy(fine)); + } + let wrong_version = [ + &b"%PDF-2.0\n%\xE2\xE3\xCF\xD3\n"[..], + b"%PDF-1.9\n%\xE2\xE3\xCF\xD3\n", + b"%PDF-1.70\n%\xE2\xE3\xCF\xD3\n", + b"%PDF-1.7 \n%\xE2\xE3\xCF\xD3\n", + b"SomeData\n%PDF-1.7\n%\xE2\xE3\xCF\xD3\n", + ]; + for wrong in wrong_version { + assert_eq!(header(wrong), version(), "{}", String::from_utf8_lossy(wrong)); + } + let wrong_comment = [ + &b"%PDF-1.7\n%\xE2\xE3\xCF\n"[..], + b"%PDF-1.7\ninvalidData\n", + b"%PDF-1.7\n%a\xE3\xCF\xD3\n", + b"%PDF-1.7\n\n%\xE2\xE3\xCF\xD3\n", + b"%PDF-1.7\n", + ]; + for wrong in wrong_comment { + assert_eq!(header(wrong), comment(), "{}", String::from_utf8_lossy(wrong)); + } + // Save as PDF/A rewrites the header: the version drops to 1.7 and the next save is full. + let mut doc = build_with_header(b"%PDF-2.0\n%ab\n", &objs, ""); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Rewrote the file header")); + assert_eq!(doc.version(), "1.7"); + assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.2")); + let saved = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + assert!(saved.starts_with(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n")); + // A good version with a short comment is rewritten too, keeping its version. + let mut doc = build_with_header(b"%PDF-1.4\n%ab\n", &objs, ""); + assert!(!doc.full_save_required()); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Rewrote the file header")); + assert!(doc.full_save_required()); + assert_eq!(doc.version(), "1.4"); + // A good header is left as it is. + let mut doc = build(&objs, ""); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(!report.fixed.iter().any(|f| f == "Rewrote the file header")); +} + +/// PDF/A-2 6.2.5: a graphics state has no transfer function, halftones of type 1 or 5 without a +/// name, and a standard rendering intent, wherever it is kept. +#[test] +fn extended_graphics_states_are_checked() { + let gs = |states: &str| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ExtGState << {states} >> >> >>"); + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &page, + "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>", + "<< /HalftoneType 1 /Frequency 60 /Angle 45 /SpotFunction /Round >>", + "<< /TR 4 0 R >>", + "<< /Type /Halftone /HalftoneType 6 /Width 2 /Height 2 /Length 4 >>\nstream\nabcd\nendstream", + "<< /Resources << /ExtGState << /X << /TR 4 0 R >> >> >> >>", + ], + "", + ); + let issues = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.5"); + issues.inspect(|i| assert!(!i.fixable, "{}", i.message)).map(|i| i.message).collect() + }; + let tr = "A graphics state has a transfer function (TR)"; + let tr2 = "A graphics state has a transfer function (TR2) other than Default"; + let name = "A halftone has a HalftoneName"; + // What 6.2.5 allows. + assert!(gs("/A << /TR2 /Default /HT 5 0 R /RI /Perceptual >> /B << /HT /Default /RI /AbsoluteColorimetric >>").is_empty()); + assert!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Cyan 5 0 R >> /RI /Saturation >> /B << /TR null >>").is_empty()); + // Transfer functions. + assert_eq!(gs("/A << /TR 4 0 R >>"), [tr]); + assert_eq!(gs("/A << /TR /Identity >>"), [tr]); + assert_eq!(gs("/A 6 0 R"), [tr]); + assert_eq!(gs("/A << /TR2 4 0 R >>"), [tr2]); + assert_eq!(gs("/A << /TR2 /Identity >>"), [tr2]); + assert_eq!(gs("/A << /HTP [0 1] >>"), ["A graphics state has a halftone phase (HTP)"]); + // Halftones: the type, also of a type 5 halftone's colorants and of a stream, and the name. + assert_eq!(gs("/A << /HT << /HalftoneType 6 >> >>"), ["A halftone has type 6: only types 1 and 5 are allowed"]); + assert_eq!(gs("/A << /HT 7 0 R >>"), ["A halftone has type 6: only types 1 and 5 are allowed"]); + assert_eq!( + gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Magenta << /HalftoneType 10 >> >> >>"), + ["A halftone has type 10: only types 1 and 5 are allowed"] + ); + assert_eq!(gs("/A << /HT << /HalftoneType 1 /HalftoneName (Screen) >> >>"), [name]); + assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default << /HalftoneType 1 /HalftoneName (S) >> >> >>"), [name]); + // Rendering intents. + assert_eq!(gs("/A << /RI /Custom >>"), ["A graphics state has the rendering intent Custom, which isn't a standard one"]); + assert_eq!(gs("/A << /RI (Perceptual) >>"), ["A graphics state has a rendering intent that isn't a name"]); + // A halftone's type is required; a null name is no name. + let untyped = ["A halftone's HalftoneType is missing or isn't an integer"]; + assert_eq!(gs("/A << /HT << /Frequency 60 >> >>"), untyped); + assert_eq!(gs("/A << /HT << /HalftoneType 1.0 >> >>"), untyped); + assert!(gs("/A << /HT << /HalftoneType 1 /HalftoneName null >> >>").is_empty()); + // Transfer functions in halftones: only, and always, for a non-primary colorant. + let in_halftone = "A halftone has a transfer function, which only a non-primary colorant's halftone may have"; + assert_eq!(gs("/A << /HT << /HalftoneType 1 /TransferFunction 4 0 R >> >>"), [in_halftone]); + assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Cyan << /HalftoneType 1 /TransferFunction /Identity >> >> >>"), [in_halftone]); + // The primary components are those of DeviceGray, DeviceRGB and DeviceCMYK (ISO 32000-1 10.5.5.6). + assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Gray << /HalftoneType 1 /TransferFunction /Identity >> >> >>"), [in_halftone]); + assert_eq!( + gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Spot 5 0 R /Red 5 0 R /Gray 5 0 R >> >>"), + ["The halftone for the colorant Spot has no transfer function"] + ); + assert!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Black 5 0 R /Spot << /HalftoneType 1 /TransferFunction 4 0 R >> >> >>").is_empty()); + // Private application data isn't graphics resources. + assert!( + gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private << /Resources << /ExtGState << /X << /TR 4 0 R >> >> >> >> >>").is_empty() + ); + // Nor is an object (8) only private data refers to; one the page refers to is read. + assert!(gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private 8 0 R >> >> /Z <<").is_empty()); + assert_eq!(gs("/A << /RI /Perceptual >> >> /Other 8 0 R /Z <<"), [tr]); + // A shading pattern holds one graphics state, not a dictionary of them. + let pattern = |gs: &str| format!("/A << /RI /Perceptual >> >> /Pattern << /P << /PatternType 2 /Shading << /ShadingType 2 >> /ExtGState {gs} >>"); + assert_eq!(gs(&pattern("<< /TR 4 0 R >>")), [tr]); + assert!(gs(&pattern("<< /HT << /HalftoneType 1 >> >>")).is_empty()); +} + +/// From the review of the 6.2.5 check: references that differ only in generation reach the same +/// object, which is read once. +#[test] +fn graphics_state_scan_reads_each_object_once_whatever_the_generation() { + let gens: String = (0..10).map(|g| format!("4 {g} R ")).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /Same [{gens}] >>"); + let doc = + build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page, "[1 2 3]"], ""); + // The catalog, the page tree, the page and object 4. + assert_eq!(gstate::reached(&doc), 4); +} + +/// PDF/A-2 6.1.3: the trailer `startxref` points to has a file identifier, two non-empty +/// strings; Save as PDF/A gives a file one. +#[test] +fn trailer_file_identifier_is_checked_and_added() { + let objs = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"]; + let id = |extra: &str| -> Vec { + let doc = build(&objs, extra); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.3").map(|i| i.message).collect() + }; + let missing = ["The trailer has no file identifier (ID)"]; + let malformed = ["The file identifier (ID) in the trailer isn't two non-empty strings"]; + assert!(id("/ID [<0011> (ab)]").is_empty()); + assert_eq!(id(""), missing); + assert_eq!(id("/ID null"), missing); + for wrong in ["/ID [<><>]", "/ID [<0011>]", "/ID [<0011> <0011> <0011>]", "/ID [1 2]", "/ID (ab)"] { + assert_eq!(id(wrong), malformed, "{wrong}"); + } + // An update without an /ID has none, whatever the trailer it replaces had. + let base = build(&objs, "/ID [<0011> <0011>]"); + let mut bytes = base.bytes().to_vec(); + let prev = bytes.windows(9).rposition(|w| w == b"startxref").unwrap(); + let prev: usize = String::from_utf8_lossy(&bytes[prev + 10..]).lines().next().unwrap().trim().parse().unwrap(); + let at = bytes.len(); + bytes.extend_from_slice( + format!("xref\n0 1\n0000000000 65535 f \ntrailer\n<< /Size 3 /Root 1 0 R /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(), + ); + let updated = Document::open(std::sync::Arc::new(bytes)).unwrap(); + assert!(verify(&updated, Level::A2b).iter().any(|i| i.message == missing[0])); + // Save as PDF/A adds one, which a save keeps. + for extra in ["", "/ID [<><>]"] { + let mut doc = build(&objs, extra); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Added a file identifier (ID)"), "{extra}"); + assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.3"), "{extra}"); + let saved = pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap(); + let saved = Document::open(std::sync::Arc::new(saved)).unwrap(); + assert!(verify(&saved, Level::A2b).iter().all(|i| i.clause != "6.1.3"), "{extra}"); + } + // A good one is kept as it is. + let mut doc = build(&objs, "/ID [<0011> <0022>]"); + let before = doc.trailer().get(b"ID").cloned(); + assert!(!convert(&mut doc, Level::A2b).unwrap().fixed.iter().any(|f| f.contains("identifier"))); + assert_eq!(doc.trailer().get(b"ID").cloned(), before); + // A malformed one keeps its non-empty first string, the permanent identifier (14.4). + let first = |doc: &Document| doc.trailer().get(b"ID").and_then(|id| id.as_array()?.first()?.as_string().map(|s| s.bytes.clone())); + let mut doc = build(&objs, "/ID [<0011> <>]"); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Completed the file identifier (ID)")); + assert_eq!(first(&doc), Some(vec![0x00, 0x11])); + assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.3")); + let mut doc = build(&objs, "/ID [<> <0011>]"); + convert(&mut doc, Level::A2b).unwrap(); + assert_eq!(first(&doc).map(|b| b.len()), Some(16)); + // A first string longer than a conforming file may hold (Annex C) is made afresh too. + let long = format!("/ID [<{}> <>]", "ab".repeat(32768)); + let mut doc = build(&objs, &long); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Added a file identifier (ID)")); + assert_eq!(first(&doc).map(|b| b.len()), Some(16)); + let longest = format!("/ID [<{}> <>]", "ab".repeat(32767)); + let mut doc = build(&objs, &longest); + convert(&mut doc, Level::A2b).unwrap(); + assert_eq!(first(&doc).map(|b| b.len()), Some(32767)); +} + +/// PDF/A-2 6.5.1: the PDF 1.2 SetState and NOP actions aren't allowed, and a Named action may +/// only name NextPage, PrevPage, FirstPage or LastPage; Save as PDF/A removes the others. +#[test] +fn set_state_no_op_and_named_actions_are_checked() { + let doc_with = |action: &str| { + let outline = format!("<< /Title (x) /Parent 4 0 R /A {action} >>"); + build( + &[ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>", + "<< /Type /Pages /Kids [] /Count 0 >>", + &outline, + "<< /Type /Outlines /First 3 0 R /Last 3 0 R /Count 1 >>", + "/NextPage", + "/Named", + "/NOP", + ], + "", + ) + }; + let actions = |action: &str| -> Vec { + verify(&doc_with(action), Level::A2b).into_iter().filter(|i| i.clause == "6.5.1").map(|i| i.message).collect() + }; + assert_eq!(actions("<< /Type /Action /S /SetState /T [] >>"), ["A SetState action isn't allowed"]); + assert_eq!(actions("<< /S /NOP >>"), ["A NOP action isn't allowed"]); + assert_eq!(actions("<< /S /Named /N /Print >>"), ["A Named Print action isn't allowed"]); + assert_eq!(actions("<< /S /Named >>"), ["A Named action isn't allowed"]); + // Behind an allowed action, in its /Next. + assert_eq!(actions("<< /S /GoTo /D [0 /Fit] /Next << /S /NOP >> >>"), ["A NOP action isn't allowed"]); + for allowed in ["NextPage", "PrevPage", "FirstPage", "LastPage"] { + assert!(actions(&format!("<< /S /Named /N /{allowed} >>")).is_empty(), "{allowed}"); + } + // The name given by reference (object 5 is /NextPage). + assert!(actions("<< /S /Named /N 5 0 R >>").is_empty()); + // The type given by reference (object 6 is /Named, 7 is /NOP). + assert_eq!(actions("<< /S 6 0 R /N /Print >>"), ["A Named Print action isn't allowed"]); + assert_eq!(actions("<< /S 7 0 R >>"), ["A NOP action isn't allowed"]); + assert!(actions("<< /S 6 0 R /N 5 0 R >>").is_empty()); + // Save as PDF/A removes them. + for action in ["<< /S /SetState /T [] >>", "<< /S /NOP >>", "<< /S /Named /N /Print >>"] { + let mut doc = doc_with(action); + convert(&mut doc, Level::A2b).unwrap(); + assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.5.1"), "{action}"); + } + // A permitted Named action survives it, also one naming its page move by reference. + for action in ["<< /S /Named /N /NextPage >>", "<< /S /Named /N 5 0 R >>"] { + let mut doc = doc_with(action); + convert(&mut doc, Level::A2b).unwrap(); + let outline = doc.get(ObjRef { num: 3, generation: 0 }); + let kept = outline + .as_dict() + .and_then(|d| d.get(b"A")) + .and_then(Object::as_dict) + .map(|a| (a.name(b"S").map(<[u8]>::to_vec), a.get(b"N").map(|n| doc.resolve(n)))); + let (s, n) = kept.unwrap_or_else(|| panic!("{action}: the action is gone")); + assert_eq!(s.as_deref(), Some(&b"Named"[..]), "{action}"); + assert_eq!(n.as_deref().and_then(Object::as_name), Some(&b"NextPage"[..]), "{action}"); + } +} + +/// The SetState, NOP and Named checks of 6.5.1 apply wherever an action can be: the catalog's +/// `/OpenAction`, an action standing alone as an object of its own (here one a page's `/AA` +/// refers to), and a link's `/A`, not only an outline item's. A Named action's `/N` given by +/// reference to a forbidden name is reported by that name. Save as PDF/A removes each one and +/// keeps a Named page move in the same places. +#[test] +fn set_state_no_op_and_named_actions_are_checked_wherever_actions_are() { + // Object 5 is the standalone action, 6 is /Print and 7 is /NextPage. + let doc_with = |open_action: &str, page_aa: &str, link_action: &str, standalone: &str| { + let catalog = format!("<< /Type /Catalog /Pages 2 0 R {open_action} >>"); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] {page_aa} >>"); + let link = format!("<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R /F 4 {link_action} >>"); + build(&[&catalog, "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page, &link, standalone, "/Print", "/NextPage"], "") + }; + let goto = "<< /S /GoTo /D [3 0 R /Fit] >>"; + // Where the action is, as (open action, page /AA, link /A, object 5). + let placed = |action: &str| { + [ + ("an open action", doc_with(&format!("/OpenAction {action}"), "", "", goto)), + ("an open action by reference", doc_with("/OpenAction 5 0 R", "", "", action)), + ("a page's /AA", doc_with("", "/AA << /O 5 0 R >>", "", action)), + ("a link's /A", doc_with("", "", &format!("/A {action}"), goto)), + ] + }; + let forbidden = [ + ("<< /S /SetState /T [] >>", "A SetState action isn't allowed"), + ("<< /S /NOP >>", "A NOP action isn't allowed"), + ("<< /S /Named /N /Print >>", "A Named Print action isn't allowed"), + ("<< /S /Named /N 6 0 R >>", "A Named Print action isn't allowed"), + ]; + let plain = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }; + for (action, message) in forbidden { + for (place, mut doc) in placed(action) { + let issues = verify(&doc, Level::A2b); + let found: Vec<&str> = issues.iter().filter(|i| i.clause == "6.5.1").map(|i| i.message.as_str()).collect(); + assert_eq!(found, [message], "{action} in {place}: {issues:#?}"); + let r = convert(&mut doc, Level::A2b).unwrap(); + let left: Vec<_> = r.remaining.iter().filter(|i| i.clause.starts_with("6.5")).collect(); + assert!(left.is_empty(), "{action} in {place}: {left:#?}"); + let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap(); + for kind in [&b"/SetState"[..], b"/NOP", b"/Named"] { + assert!(!bytes.windows(kind.len()).any(|w| w == kind), "{action} in {place}: {}", String::from_utf8_lossy(&bytes)); + } + } + } + // A Named page move, with its name given by reference, is allowed and kept outside /AA. + for (place, mut doc) in placed("<< /S /Named /N 7 0 R >>") { + let issues = verify(&doc, Level::A2b); + assert!(!issues.iter().any(|i| i.clause == "6.5.1"), "{place}: {issues:#?}"); + convert(&mut doc, Level::A2b).unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap(); + let kept = bytes.windows(6).any(|w| w == b"/Named"); + assert_eq!(kept, place != "a page's /AA", "{place}: {}", String::from_utf8_lossy(&bytes)); + } +} + +/// 6.2.10: without a PDF/A output intent, a page whose resources can paint transparently names its +/// blending colour space in its `/Group`. Transparency is found through graphics states, images, +/// transparency groups, nested form XObjects, Type 3 fonts, patterns and annotation appearances. +#[test] +fn a_page_with_transparency_and_no_output_intent_names_its_blending_space() { + // With `intent`, the catalog has a PDF/A output intent, its profile the last object. + let blending = |intent: bool, page_extra: &str, resources: &str, extra: &[&str]| -> Vec { + let profile = extra.len() + 4; + let intents = + format!("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB) /DestOutputProfile {profile} 0 R >>]"); + let catalog = format!("<< /Type /Catalog /Pages 2 0 R {} >>", if intent { intents.as_str() } else { "" }); + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> {page_extra} >>"); + let mut objs: Vec<&str> = vec![&catalog, "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page]; + objs.extend_from_slice(extra); + if intent { + objs.push( + "<< /N 3 /Length 0 >> +stream + +endstream", + ); + } + verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect() + }; + let said = ["Page 1 has transparency but no device-independent blending colour space or PDF/A output intent"]; + let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); + let gs = |entries: &str| format!("/ExtGState << /G << {entries} >> >>"); + for (what, resources, extra) in [ + ("fill alpha", gs("/ca 0.5"), vec![]), + ("stroke alpha", gs("/CA 0.25"), vec![]), + ("blend mode", gs("/BM /Darken"), vec![]), + ("blend mode array", gs("/BM [/Multiply /Normal]"), vec![]), + ("soft mask", gs("/SMask << /S /Luminosity /G 4 0 R >>"), vec![form("")]), + ( + "image soft mask", + "/XObject << /Im 4 0 R >>".to_owned(), + vec![ + "<< /Subtype /Image /Width 1 /Height 1 /SMask 5 0 R /Length 0 >>\nstream\n\nendstream".to_owned(), + "<< /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 0 >>\nstream\n\nendstream".to_owned(), + ], + ), + ( + "SMaskInData", + "/XObject << /Im 4 0 R >>".to_owned(), + vec!["<< /Subtype /Image /Width 1 /Height 1 /SMaskInData 1 /Length 0 >>\nstream\n\nendstream".to_owned()], + ), + ("transparency group", "/XObject << /Fm 4 0 R >>".to_owned(), vec![form("/Group << /S /Transparency >>")]), + ( + "nested form", + "/XObject << /Fm 4 0 R >>".to_owned(), + vec![form("/Resources << /XObject << /In 5 0 R >> >>"), form("/Resources << /ExtGState << /G << /ca 0.5 >> >> >>")], + ), + ( + "Type 3 font", + "/Font << /T3 4 0 R >>".to_owned(), + vec!["<< /Type /Font /Subtype /Type3 /Resources << /ExtGState << /G << /CA 0.5 >> >> >> >>".to_owned()], + ), + ("tiling pattern", "/Pattern << /P 4 0 R >>".to_owned(), vec![form("/PatternType 1 /Resources << /ExtGState << /G << /BM /Screen >> >> >>")]), + ("shading pattern", "/Pattern << /P << /PatternType 2 /ExtGState << /ca 0.5 >> >> >>".to_owned(), vec![]), + ] { + let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); + assert_eq!(blending(false, "", &resources, &extra), said, "{what}"); + // A page group with a blending colour space, or a PDF/A output intent, satisfies the rule. + assert!( + blending(false, "/Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >>", &resources, &extra).is_empty(), + "{what}: page /CS" + ); + assert_eq!(blending(false, "/Group << /S /Transparency >>", &resources, &extra), said, "{what}: page group without /CS"); + assert!(blending(true, "", &resources, &extra).is_empty(), "{what}: output intent"); + } + // Annotation appearances, as one stream or a dictionary of streams by state. + let annot = |ap: &str| format!("<< /Type /Annot /Subtype /Widget /Rect [0 0 10 10] /AP << /N {ap} >> >>"); + assert_eq!(blending(false, "/Annots [4 0 R]", "", &[&annot("5 0 R"), &form("/Group << /S /Transparency >>")]), said, "appearance"); + assert_eq!( + blending(false, "/Annots [4 0 R]", "", &[&annot("<< /On 5 0 R /Off 6 0 R >>"), &form(""), &form("/Group << /S /Transparency >>")]), + said, + "appearance states" + ); + // Opaque painting needs no blending colour space: alpha 1, Normal or Compatible blending, no + // soft mask, an image without one, a form that is no transparency group. + for (what, resources, extra) in [ + ("opaque", gs("/CA 1 /ca 1.0 /BM /Normal /SMask /None"), vec![]), + ("compatible", gs("/BM [/Compatible /Darken]"), vec![]), + ( + "image", + "/XObject << /Im 4 0 R >>".to_owned(), + vec!["<< /Subtype /Image /Width 1 /Height 1 /SMaskInData 0 /Length 0 >>\nstream\n\nendstream".to_owned()], + ), + ( + "plain form, its resources cycling back to itself", + "/XObject << /Fm 4 0 R >>".to_owned(), + vec![form("/Resources << /XObject << /Me 4 0 R >> >>")], + ), + ( + "Type 1 font", + "/Font << /F 4 0 R >>".to_owned(), + vec!["<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Resources << /ExtGState << /G << /ca 0.5 >> >> >> >>".to_owned()], + ), + ] { + let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); + assert!(blending(false, "", &resources, &extra).is_empty(), "{what}"); + } +} + +/// 6.2.10: a graphics state's blend mode, or each name of a blend mode array, is one ISO 32000-1 +/// 11.3.5 defines, whether the graphics state is an object of its own or written in a page's +/// resources. +#[test] +fn blend_modes_are_the_ones_iso_32000_1_defines() { + let modes = |resources: &str, extra: &[&str]| -> Vec { + let page = format!( + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << {resources} >> >>" + ); + let mut objs: Vec<&str> = + vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page]; + objs.extend_from_slice(extra); + verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect() + }; + let custom = ["A graphics state uses the blend mode Custom, which ISO 32000-1 doesn't define"]; + assert_eq!(modes("/ExtGState << /G 4 0 R >>", &["<< /BM /Custom >>"]), custom, "an object of its own"); + assert_eq!(modes("/ExtGState << /G << /BM /Custom >> >>", &[]), custom, "written in the resources"); + assert_eq!(modes("/ExtGState << /G << /BM [/Multiply /Custom] >> >>", &[]), custom, "in an array"); + let all = "/Normal /Compatible /Multiply /Screen /Overlay /Darken /Lighten /ColorDodge /ColorBurn /HardLight /SoftLight /Difference /Exclusion /Hue /Saturation /Color /Luminosity"; + assert!(modes(&format!("/ExtGState << /G << /BM [{all}] >> >>"), &[]).is_empty()); +} + +/// 6.2.10's blending colour space is one PDF/A accepts without an output intent: not null, and +/// not a device space unless the page gives it a default (6.2.4.3). An output intent stands in +/// for it only with a profile. +#[test] +fn a_blending_space_is_device_independent_and_an_intent_has_a_profile() { + let page_said = ["Page 1 has transparency but no device-independent blending colour space or PDF/A output intent"]; + let check = |catalog_extra: &str, page_extra: &str, extra: &[&str]| -> Vec { + let catalog = format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"); + let page = format!("<< /Type /Page /Parent 2 0 R {page_extra} >>"); + let mut objs: Vec<&str> = vec![&catalog, "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page]; + objs.extend_from_slice(extra); + verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect() + }; + let alpha = "/ExtGState << /G << /ca 0.5 >> >>"; + let page = |group: &str, colour_spaces: &str| format!("/Group << /S /Transparency {group} >> /Resources << {alpha} {colour_spaces} >>"); + assert_eq!(check("", &page("/CS null", ""), &[]), page_said, "a null /CS"); + assert_eq!(check("", &page("/CS 9 0 R", ""), &[]), page_said, "a /CS naming no object"); + // A device space needs a default of as many components (ISO 32000-1 8.6.5.6). + let cmyk_profile = "<< /N 4 /Length 0 >>\nstream\n\nendstream"; + let cal = |family: &str| format!("[/{family} << /WhitePoint [0.9505 1 1.089] >>]"); + for (device, default, space) in [ + ("DeviceGray", "DefaultGray", cal("CalGray")), + ("DeviceRGB", "DefaultRGB", cal("CalRGB")), + ("DeviceCMYK", "DefaultCMYK", "[/ICCBased 4 0 R]".to_owned()), + ] { + assert_eq!(check("", &page(&format!("/CS /{device}"), ""), &[cmyk_profile]), page_said, "{device}"); + let defaults = format!("/ColorSpace << /{default} {space} >>"); + assert!(check("", &page(&format!("/CS /{device}"), &defaults), &[cmyk_profile]).is_empty(), "{device} with {default}"); + let null = format!("/ColorSpace << /{default} null >>"); + assert_eq!(check("", &page(&format!("/CS /{device}"), &null), &[cmyk_profile]), page_said, "{device} with a null {default}"); + } + let wrong = format!("/ColorSpace << /DefaultGray {} >>", cal("CalRGB")); + assert_eq!(check("", &page("/CS /DeviceGray", &wrong), &[]), page_said, "DeviceGray with a CalRGB default"); + // Only CalGray, CalRGB and gray, RGB or CMYK ICCBased spaces blend (ISO 32000-1 11.3.4). + assert!(check("", &page("/CS [/CalGray << /WhitePoint [0.9505 1 1.089] >>]", ""), &[]).is_empty(), "CalGray"); + let icc = |n: u8, data: &str| format!("<< /N {n} /Length {} >>\nstream\n{data}\nendstream", data.len()); + assert!(check("", &page("/CS [/ICCBased 4 0 R]", ""), &[&icc(3, "")]).is_empty(), "an ICC profile of three components"); + for (what, cs, extra) in [ + ("Indexed", "/CS [/Indexed /DeviceRGB 1 <000000FFFFFF>]".to_owned(), vec![]), + ("CalRGB without its dictionary", "/CS [/CalRGB]".to_owned(), vec![]), + ("CalGray without a white point", "/CS [/CalGray << >>]".to_owned(), vec![]), + ("CalRGB with a short white point", "/CS [/CalRGB << /WhitePoint [0.9505 1] >>]".to_owned(), vec![]), + ("CalRGB with a zero white point", "/CS [/CalRGB << /WhitePoint [0 0 0] >>]".to_owned(), vec![]), + ("CalGray with a white point whose Y isn't 1", "/CS [/CalGray << /WhitePoint [0.9505 0.5 1.089] >>]".to_owned(), vec![]), + ("CalRGB with a non-number in its white point", "/CS [/CalRGB << /WhitePoint [0.9505 1 /Z 1.089] >>]".to_owned(), vec![]), + ("Lab", "/CS [/Lab << /WhitePoint [0.9505 1 1.089] >>]".to_owned(), vec![]), + ( + "Separation", + "/CS [/Separation /Spot /DeviceCMYK 4 0 R]".to_owned(), + vec!["<< /FunctionType 2 /Domain [0 1] /C1 [0 0 0 1] /N 1 >>".to_owned()], + ), + ("an ICC profile of two components", "/CS [/ICCBased 4 0 R]".to_owned(), vec![icc(2, "")]), + ("a Lab ICC profile", "/CS [/ICCBased 4 0 R]".to_owned(), vec![icc(3, &format!("{}Lab ", "\0".repeat(16)))]), + ( + "an ICC profile of one component whose header says RGB", + "/CS [/ICCBased 4 0 R]".to_owned(), + vec![icc(1, &format!("{}RGB ", "\0".repeat(16)))], + ), + ("a pattern space", "/CS /Pattern".to_owned(), vec![]), + ] { + let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); + assert_eq!(check("", &page(&cs, ""), &extra), page_said, "{what}"); + } + // A null soft mask is none: an otherwise opaque graphics state is not transparency. + assert!(check("", "/Resources << /ExtGState << /G << /SMask null >> >> >>", &[]).is_empty(), "/SMask null"); + let intent = |profile: &str| format!("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB) {profile} >>]"); + let opaque_group = format!("/Resources << {alpha} >>"); + assert_eq!(check(&intent("/DestOutputProfile null"), &opaque_group, &[]), page_said, "a null profile"); + assert_eq!(check(&intent(""), &opaque_group, &[]), page_said, "no profile"); + assert!(check(&intent("/DestOutputProfile 4 0 R"), &opaque_group, &["<< /N 3 /Length 0 >>\nstream\n\nendstream"]).is_empty(), "a profile"); + // Private data is no graphics state: a /BM there is not a blend mode. + assert!(check("/PieceInfo << /App << /Private << /BM /Bookkeeping >> >> >>", "", &[]).is_empty(), "/PieceInfo"); +} + +/// A form XObject first reached at the depth limit, where its resources go unread, is read again +/// when a shallower place names it, so what it holds is not missed. +#[test] +fn transparency_reached_first_too_deep_is_found_from_a_shallower_place() { + let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); + // A chain of 32 forms, objects 4 to 35, the last naming form 36, which holds a translucent + // graphics state; the page names the chain first and form 36 after it. + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /Resources << /XObject << /A 4 0 R /B 36 0 R >> >> >>".to_owned(), + ]; + for n in 4..=35 { + objs.push(form(&format!("/Resources << /XObject << /X {} 0 R >> >>", n + 1))); + } + objs.push(form("/Resources << /ExtGState << /G << /ca 0.5 >> >> >>")); + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let said: Vec = verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect(); + assert_eq!(said, ["Page 1 has transparency but no device-independent blending colour space or PDF/A output intent"]); +} + +/// 6.2.10: an unknown blend mode is reported on the page whose resources reach it, as the +/// transparency issue is, each page once. +#[test] +fn an_unknown_blend_mode_names_its_page() { + let page = "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << /ExtGState << /G 5 0 R >> >> >>"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R >>", + page, + "<< /BM /Custom >>", + ]; + let found: Vec<(String, Option)> = + verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| (i.message, i.page)).collect(); + assert_eq!(found, [("A graphics state uses the blend mode Custom, which ISO 32000-1 doesn't define".to_owned(), Some(1))]); +} + +/// A `/BM` array of many unknown names gathers a bounded number of them. +#[test] +fn unknown_blend_mode_names_are_capped() { + let names: String = (0..1000).map(|k| format!("/Custom{k} ")).collect(); + let page = format!( + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << /ExtGState << /G << /BM [/Multiply {names}] >> >> >> >>" + ); + let objs = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page]; + let said: Vec = verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect(); + assert_eq!(said.len(), 32); + assert_eq!(said.first().map(String::as_str), Some("A graphics state uses the blend mode Custom0, which ISO 32000-1 doesn't define")); +} + +/// Pages that share a form read it once: the next page takes what was found under it from +/// [`transparency::Shared`] in one read, and still reports it. +#[test] +fn pages_sharing_a_form_read_it_once() { + let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); + let pages = 50; + let first_form = 3 + pages; + let kids: String = (0..pages).map(|k| format!("{} 0 R ", 3 + k)).collect(); + let mut objs = + vec!["<< /Type /Catalog /Pages 2 0 R >>".to_owned(), format!("<< /Type /Pages /Kids [{kids}] /Count {pages} /MediaBox [0 0 200 200] >>")]; + for _ in 0..pages { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Resources << /XObject << /F {first_form} 0 R >> >> >>")); + } + // A chain of ten forms, each with twenty graphics states, the last one translucent. + let gstates: String = (0..20).map(|k| format!("/G{k} << /CA 1 >> ")).collect(); + for n in 0..10 { + let next = if n < 9 { format!("/XObject << /X {} 0 R >>", first_form + n + 1) } else { "/ExtGState << /T << /ca 0.5 >> >>".to_owned() }; + objs.push(form(&format!("/Resources << /ExtGState << {gstates} >> {next} >>"))); + } + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let doc = build(&objs, ""); + let list = pdfcraft_model::pages(&doc); + let mut shared = transparency::Shared::default(); + let reads: Vec = list.iter().map(|p| transparency::page_reads(&doc, &p.dict, &mut shared)).collect(); + assert!(reads.first().is_some_and(|&n| n > 200), "the first page reads the chain: {reads:?}"); + assert!(reads.iter().skip(1).all(|&n| n == 2), "the others its resources and the form: {reads:?}"); + let said = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").count(); + assert_eq!(said, pages, "every page has transparency"); +} + +/// A form whose reading stopped at the depth limit on one page is not kept as read: a page that +/// names it from a shallower place reads what lies below it. +#[test] +fn a_form_cut_short_on_one_page_is_read_again_on_the_next() { + let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); + // Page 1 (object 3) names a chain of 33 forms, objects 5 to 37, whose last holds a translucent + // graphics state beyond the depth limit; page 2 (object 4) names form 20, from which it is near. + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /Resources << /XObject << /A 5 0 R >> >> >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /Resources << /XObject << /A 20 0 R >> >> >>".to_owned(), + ]; + for n in 5..=36 { + objs.push(form(&format!("/Resources << /XObject << /X {} 0 R >> >>", n + 1))); + } + objs.push(form("/Resources << /ExtGState << /G << /ca 0.5 >> >> >>")); + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let said: Vec = verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect(); + assert_eq!(said, ["Page 2 has transparency but no device-independent blending colour space or PDF/A output intent"]); +} diff --git a/crates/preflight/src/trailer.rs b/crates/preflight/src/trailer.rs new file mode 100644 index 0000000000..7c9d134bec --- /dev/null +++ b/crates/preflight/src/trailer.rs @@ -0,0 +1,55 @@ +//! PDF/A-2 6.1.3, the file trailer: it has an `/ID`, a file identifier as ISO 32000-1 14.4 +//! defines it, an array of two byte strings. +//! +//! The trailer read is the one the file's last `startxref` points to: the latest update's, or a +//! linearized file's first-page trailer (ISO 32000-1 F.3.4). Trailers earlier in the chain don't +//! stand in for it. + +use pdfcraft_cos::{Document, Object}; + +/// The longest string a conforming file may hold (ISO 32000-1 Annex C, Table C.1). +const MAX_STRING: usize = 32767; + +/// What is wrong with the file identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IdProblem { + /// The trailer has no `/ID`, or a null one. + Missing, + /// The `/ID` isn't an array of two non-empty strings. + Malformed, +} + +/// What 6.1.3 finds wrong with the file identifier of `doc`, if anything. +pub(crate) fn check(doc: &Document) -> Option { + let id = doc.trailer().get(b"ID").map(|id| doc.resolve(id)).filter(|id| !matches!(**id, Object::Null)); + let Some(id) = id else { return Some(IdProblem::Missing) }; + let strings = |parts: &[Object]| parts.iter().all(|p| doc.resolve(p).as_string().is_some_and(|s| !s.bytes.is_empty())); + match id.as_array() { + Some(parts) if parts.len() == 2 && strings(parts) => None, + _ => Some(IdProblem::Malformed), + } +} + +/// Gives `doc` a file identifier when it lacks a good one; what it fixed, if anything. A first +/// string a conforming file can keep (non-empty, within [`MAX_STRING`]) is the permanent +/// identifier (ISO 32000-1 14.4): it is kept, and only the second is made afresh. Otherwise both +/// are new, and the report says the identifier was added rather than completed. +pub(crate) fn fix(doc: &mut Document) -> Option { + check(doc)?; + let Object::Array(mut id) = pdfcraft_cos::new_file_id(doc.bytes(), 0) else { return None }; + let permanent = doc + .trailer() + .get(b"ID") + .map(|old| doc.resolve(old)) + .and_then(|old| old.as_array().and_then(|parts| parts.first().map(|p| doc.resolve(p)))) + .filter(|first| first.as_string().is_some_and(|s| (1..=MAX_STRING).contains(&s.bytes.len()))); + let kept = match (permanent, id.first_mut()) { + (Some(permanent), Some(slot)) => { + *slot = (*permanent).clone(); + true + } + _ => false, + }; + doc.trailer_mut().set(b"ID".to_vec(), Object::Array(id)); + Some(if kept { "Completed the file identifier (ID)" } else { "Added a file identifier (ID)" }.into()) +} diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs new file mode 100644 index 0000000000..605bf4dfae --- /dev/null +++ b/crates/preflight/src/transparency.rs @@ -0,0 +1,391 @@ +//! PDF/A-2 6.2.10: a graphics state's blend mode is one ISO 32000-1 defines, and a page that +//! contains transparency, in a file without a PDF/A output intent, names its blending colour +//! space: its `/Group` dictionary has a `/CS` entry, which as any colour space in such a file is +//! not a device space without a default (6.2.4.3). +//! +//! A page contains transparency when something its resources reach can paint transparently (ISO +//! 32000-1 11.6.4.4 to 11.6.6): a graphics state with a constant alpha (`/CA`, `/ca`) below 1, +//! a blend mode other than `Normal` or `Compatible`, or a soft mask; an image with a soft mask +//! (`/SMask`, or `/SMaskInData` above 0); or a transparency group XObject. The resources looked +//! through are the page's own, those of the form XObjects, tiling patterns and Type 3 fonts they +//! hold, and those of the appearance streams of the page's annotations. Whether the content uses +//! a resource is not asked: a resource a page holds is one it may paint with. Blend modes are read +//! from the same graphics states, so private data (`/PieceInfo`) is never taken for one. + +use std::collections::HashMap; +use std::sync::Arc; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +/// How many dictionaries one page's search reads, across its resources, XObjects, patterns, Type +/// 3 fonts and appearance streams. An object [`Shared`] holds counts as one read. +const MAX_VISITS: usize = 4096; +/// How deep resources nest (a form XObject in a form XObject, ...). +const MAX_DEPTH: usize = 32; +/// The resource categories that hold what a page paints with, read in this order. +const CATEGORIES: [&[u8]; 4] = [b"ExtGState", b"XObject", b"Pattern", b"Font"]; +/// How many unknown blend mode names one search gathers: enough to name them in the report, +/// without a `/BM` array of a million names comparing each with all the others. +const MAX_BLEND_NAMES: usize = 32; +/// How many objects [`Shared`] keeps what was found under. +const MAX_SHARED: usize = 1 << 16; + +/// What the graphics a page's resources reach paint with. +#[derive(Clone, Debug, Default)] +pub(crate) struct Painting { + /// Something paints transparently. + pub(crate) transparency: bool, + /// The blend modes graphics states name that ISO 32000-1 doesn't define. + pub(crate) unknown_blend_modes: Vec, +} + +impl Painting { + fn add(&mut self, other: &Painting) { + self.transparency |= other.transparency; + for name in &other.unknown_blend_modes { + self.add_blend_mode(name); + } + } + + /// Whether no more unknown blend mode names are gathered. + fn blend_modes_full(&self) -> bool { + self.unknown_blend_modes.len() >= MAX_BLEND_NAMES + } + + fn add_blend_mode(&mut self, name: &str) { + if !self.blend_modes_full() && !self.unknown_blend_modes.iter().any(|n| n == name) { + self.unknown_blend_modes.push(name.to_owned()); + } + } +} + +/// What was found under the indirect objects (resources, form XObjects, patterns, Type 3 fonts, +/// graphics states) that pages share, so the next page that names one takes it from here instead +/// of walking it again. Only an object read to the end goes in: none whose reading stopped below +/// it at the budget, at the depth limit, or at an object already read on that page (a cycle). +#[derive(Default)] +pub(crate) struct Shared(HashMap); + +/// An object read in place, or the object a reference names as the document holds it: either way +/// nothing is copied. +enum Held<'o> { + Direct(&'o Object), + Resolved(Arc), +} + +impl std::ops::Deref for Held<'_> { + type Target = Object; + + fn deref(&self) -> &Object { + match self { + Held::Direct(o) => o, + Held::Resolved(o) => o, + } + } +} + +/// `o`, or the object it refers to, without copying either ([`Document::resolve`] copies a direct +/// object). +fn held<'o>(doc: &Document, o: &'o Object) -> Held<'o> { + match o { + Object::Ref(_) => Held::Resolved(doc.resolve(o)), + o => Held::Direct(o), + } +} + +/// What the page `page` paints with: its resources, and the appearance streams of its +/// annotations. `shared` carries what earlier pages found under the objects they share. +pub(crate) fn page_painting(doc: &Document, page: &Dict, shared: &mut Shared) -> Painting { + walk_page(doc, page, shared).found +} + +/// How many reads the search of `page` took, each object taken from `shared` counting as one. +#[cfg(test)] +pub(crate) fn page_reads(doc: &Document, page: &Dict, shared: &mut Shared) -> usize { + walk_page(doc, page, shared).visits +} + +fn walk_page<'a>(doc: &'a Document, page: &Dict, shared: &'a mut Shared) -> Walk<'a> { + let mut walk = Walk { doc, shared, seen: HashMap::new(), visits: 0, cuts: 0, found: Painting::default() }; + if let Some(r) = page.get(b"Resources") { + walk.visit(r, 0, Walk::resources); + } + let annots = page.get(b"Annots").map(|a| held(doc, a)); + for a in annots.as_deref().and_then(Object::as_array).into_iter().flatten() { + let a = held(doc, a); + let Some(ap) = a.as_dict().and_then(|a| a.get(b"AP")).map(|ap| held(doc, ap)) else { continue }; + let Some(ap) = ap.as_dict() else { continue }; + // Each appearance (normal, rollover, down) is a stream, or a dictionary of streams by state. + for (_, v) in ap.iter() { + match &*held(doc, v) { + Object::Stream(_) => walk.visit(v, 0, Walk::xobject), + Object::Dict(states) => { + for (_, s) in states.iter() { + walk.visit(s, 0, Walk::xobject); + } + } + _ => {} + } + } + } + walk +} + +/// Whether `page` names a blending colour space PDF/A accepts without an output intent: its +/// `/Group` has a `/CS` that is a blending colour space (ISO 32000-1 and 32000-2 11.3.4: +/// `CalGray`, `CalRGB`, or an `ICCBased` space of 1, 3 or 4 components whose profile describes +/// gray, RGB or CMYK; not Lab, Indexed, Pattern, Separation or DeviceN), and not a device colour +/// space unless the page's resources give that device space a default (`DefaultGray`, +/// `DefaultRGB`, `DefaultCMYK`) that is itself such a space with as many components (ISO +/// 32000-1 8.6.5.6), as PDF/A-2 6.2.4.3 asks of any device colour. +pub(crate) fn page_names_blending_space(doc: &Document, page: &Dict) -> bool { + let group = page.get(b"Group").map(|g| doc.resolve(g)); + let Some(cs) = group.as_deref().and_then(Object::as_dict).and_then(|g| g.get(b"CS")).map(|c| doc.resolve(c)) else { return false }; + let (default, components): (&[u8], i64) = match &*cs { + Object::Name(n) if n == b"DeviceGray" => (b"DefaultGray", 1), + Object::Name(n) if n == b"DeviceRGB" => (b"DefaultRGB", 3), + Object::Name(n) if n == b"DeviceCMYK" => (b"DefaultCMYK", 4), + Object::Array(a) => return independent_blending_space(doc, a).is_some(), + _ => return false, + }; + let resources = page.get(b"Resources").map(|r| doc.resolve(r)); + let spaces = resources.as_deref().and_then(Object::as_dict).and_then(|r| r.get(b"ColorSpace")).map(|c| doc.resolve(c)); + let space = spaces.as_deref().and_then(Object::as_dict).and_then(|s| s.get(default)).map(|d| doc.resolve(d)); + space.as_deref().and_then(Object::as_array).and_then(|a| independent_blending_space(doc, a)) == Some(components) +} + +/// The number of components of the colour space array `a` when it is a device-independent +/// blending colour space: `CalGray` (1), `CalRGB` (3), or `ICCBased` with 1, 3 or 4 components +/// whose profile header, where it can be read (within the output intent's profile cap), says +/// gray, RGB or CMYK to match. +fn independent_blending_space(doc: &Document, a: &[Object]) -> Option { + let family = a.first().map(|f| doc.resolve(f)); + // A CalGray or CalRGB space needs its dictionary, with the required `/WhitePoint` [X Y Z]: X + // and Z positive, Y 1 (ISO 32000-1 Tables 63 and 64). + let calibrated = || { + let params = a.get(1).map(|p| doc.resolve(p)); + let white = params.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"WhitePoint")).map(|w| doc.resolve(w)); + let values: Option> = white.as_deref().and_then(Object::as_array).and_then(|w| w.iter().map(|v| doc.resolve(v).as_f64()).collect()); + matches!(values.as_deref(), Some(&[x, y, z]) if x > 0.0 && y == 1.0 && z > 0.0) + }; + match family.as_deref().and_then(Object::as_name)? { + b"CalGray" => calibrated().then_some(1), + b"CalRGB" => calibrated().then_some(3), + b"ICCBased" => { + let profile = a.get(1).map(|p| doc.resolve(p)); + let Some(Object::Stream(s)) = profile.as_deref() else { return None }; + let components = s.dict.get(b"N").and_then(|n| doc.resolve(n).as_int())?; + // ICC.1 header: the data colour space is bytes 16–19. + let header: Option = match s.decoded_within(crate::MAX_ICC_PROFILE).ok().and_then(|data| data.get(16..20).map(<[u8]>::to_vec)) { + Some(space) => match space.as_slice() { + b"GRAY" => Some(1), + b"RGB " => Some(3), + b"CMYK" => Some(4), + _ => return None, + }, + None => None, + }; + (matches!(components, 1 | 3 | 4) && header.is_none_or(|h| h == components)).then_some(components) + } + _ => None, + } +} + +struct Walk<'a> { + doc: &'a Document, + shared: &'a mut Shared, + /// The indirect objects already read on this page, each with the shallowest depth it was read + /// at: an object is read again only from a shallower place, so a cycle ends and a deep first + /// visit doesn't hide what a shallower one would read. + seen: HashMap, + visits: usize, + /// How many times reading stopped short (the budget, the depth limit, an object already read + /// on this page). An object with a stop below it doesn't go in [`Shared`]: what was found + /// under it may be incomplete. + cuts: usize, + found: Painting, +} + +impl Walk<'_> { + /// Read `o`, reached `depth` levels down, with `read`: from [`Shared`] when an earlier page + /// read it to the end, else when it is within the budget and the depth, and not an indirect + /// object read before on this page from as shallow a place. + fn visit(&mut self, o: &Object, depth: usize, read: fn(&mut Self, &Object, usize)) { + if self.visits >= MAX_VISITS || depth > MAX_DEPTH { + self.cuts = self.cuts.saturating_add(1); + return; + } + let Object::Ref(r) = o else { + self.visits += 1; + read(self, o, depth); + return; + }; + if let Some(found) = self.shared.0.get(r) { + self.visits += 1; + self.found.add(found); + return; + } + if self.seen.get(r).is_some_and(|&d| d <= depth) { + self.cuts = self.cuts.saturating_add(1); + return; + } + self.seen.insert(*r, depth); + self.visits += 1; + // Gather what this object holds on its own, to keep for the next page, then add it to what + // the page holds. + let outer = std::mem::take(&mut self.found); + let cuts = self.cuts; + read(self, o, depth); + let below = std::mem::replace(&mut self.found, outer); + self.found.add(&below); + if self.cuts == cuts && self.shared.0.len() < MAX_SHARED { + self.shared.0.insert(*r, below); + } + } + + fn resources(&mut self, r: &Object, depth: usize) { + let doc = self.doc; + let res = held(doc, r); + let Some(res) = res.as_dict() else { return }; + for category in CATEGORIES { + let entries = res.get(category).map(|d| held(doc, d)); + let Some(entries) = entries.as_deref().and_then(Object::as_dict) else { continue }; + for (_, v) in entries.iter() { + // Once the budget is spent nothing more is read, so nothing more is gathered either. + if self.visits >= MAX_VISITS { + self.cuts = self.cuts.saturating_add(1); + return; + } + match category { + b"ExtGState" => self.visit(v, depth, Self::ext_gstate), + b"XObject" => self.visit(v, depth, Self::xobject), + b"Pattern" => self.visit(v, depth, Self::pattern), + _ => self.visit(v, depth, Self::type3), + } + } + } + } + + /// A graphics state (ISO 32000-1 Table 58): whether it paints transparently, and the blend + /// modes it names that ISO 32000-1 doesn't define. + fn ext_gstate(&mut self, g: &Object, _depth: usize) { + let doc = self.doc; + let g = held(doc, g); + let Some(g) = g.as_dict() else { return }; + let below_one = |key: &[u8]| g.get(key).and_then(|v| held(doc, v).as_f64()).is_some_and(|a| a < 1.0); + let bm = g.get(b"BM").map(|b| held(doc, b)); + let blend = bm.as_deref().is_some_and(|b| blends(doc, b)); + // A soft mask is a dictionary; `/None`, null or anything else is none (ISO 32000-1 Table 58). + let mask = g.get(b"SMask").map(|m| held(doc, m)).is_some_and(|m| matches!(&*m, Object::Dict(_))); + self.found.transparency |= below_one(b"CA") || below_one(b"ca") || blend || mask; + if let Some(bm) = bm.as_deref() { + unknown_blend_modes(doc, bm, &mut self.found); + } + } + + /// An image with a soft mask, or a form XObject that is a transparency group or holds + /// transparency in its resources. + fn xobject(&mut self, x: &Object, depth: usize) { + let doc = self.doc; + let x = held(doc, x); + let Object::Stream(s) = &*x else { return }; + let d = &s.dict; + if d.name(b"Subtype") == Some(b"Image") { + let soft_mask = d.get(b"SMask").is_some_and(|m| matches!(&*held(doc, m), Object::Stream(_))); + let in_data = d.get(b"SMaskInData").and_then(|v| held(doc, v).as_int()).is_some_and(|v| v > 0); + self.found.transparency |= soft_mask || in_data; + return; + } + let group = d.get(b"Group").map(|g| held(doc, g)).is_some_and(|g| g.as_dict().is_some_and(|g| g.name(b"S") == Some(b"Transparency"))); + self.found.transparency |= group; + if let Some(r) = d.get(b"Resources") { + self.visit(r, depth + 1, Self::resources); + } + } + + /// A tiling pattern's resources, or a shading pattern's graphics state. + fn pattern(&mut self, p: &Object, depth: usize) { + let p = held(self.doc, p); + let Some(d) = (match &*p { + Object::Stream(s) => Some(&s.dict), + Object::Dict(d) => Some(d), + _ => None, + }) else { + return; + }; + if let Some(g) = d.get(b"ExtGState") { + self.visit(g, depth + 1, Self::ext_gstate); + } + if let Some(r) = d.get(b"Resources") { + self.visit(r, depth + 1, Self::resources); + } + } + + /// A Type 3 font's resources, which its glyph procedures paint with. + fn type3(&mut self, f: &Object, depth: usize) { + let f = held(self.doc, f); + let Some(d) = f.as_dict() else { return }; + if d.name(b"Subtype") != Some(b"Type3") { + return; + } + if let Some(r) = d.get(b"Resources") { + self.visit(r, depth + 1, Self::resources); + } + } +} + +/// The blend modes of ISO 32000-1 11.3.5 (Tables 136 and 137), and `Compatible` (Table 58). +const BLEND_MODES: &[&[u8]] = &[ + b"Normal", + b"Compatible", + b"Multiply", + b"Screen", + b"Overlay", + b"Darken", + b"Lighten", + b"ColorDodge", + b"ColorBurn", + b"HardLight", + b"SoftLight", + b"Difference", + b"Exclusion", + b"Hue", + b"Saturation", + b"Color", + b"Luminosity", +]; + +/// Add to `found` the names in a `/BM` value (a name, or an array of them) that are not blend +/// modes ISO 32000-1 defines, up to [`MAX_BLEND_NAMES`] of them. +fn unknown_blend_modes(doc: &Document, bm: &Object, found: &mut Painting) { + let one = |found: &mut Painting, n: &[u8]| { + if !BLEND_MODES.contains(&n) { + found.add_blend_mode(&String::from_utf8_lossy(n)); + } + }; + match bm { + Object::Name(n) => one(found, n), + Object::Array(a) => { + for m in a { + if found.blend_modes_full() { + return; + } + if let Some(n) = held(doc, m).as_name() { + one(found, n); + } + } + } + _ => {} + } +} + +/// Whether a `/BM` value blends: a name other than `Normal` or `Compatible`, or an array whose +/// first name is not one of those. +fn blends(doc: &Document, b: &Object) -> bool { + let normal = |n: &[u8]| n == b"Normal" || n == b"Compatible"; + match b { + Object::Name(n) => !normal(n), + Object::Array(a) => a.iter().find_map(|m| doc.resolve(m).as_name().map(|n| !normal(n))).unwrap_or(false), + _ => false, + } +} diff --git a/crates/preflight/src/xmp_extension.rs b/crates/preflight/src/xmp_extension.rs new file mode 100644 index 0000000000..92b3afa38e --- /dev/null +++ b/crates/preflight/src/xmp_extension.rs @@ -0,0 +1,251 @@ +//! PDF/A-2 6.6.2.3.3, the extension schema container schema: a packet describes each schema it +//! extends XMP with in `pdfaExtension:schemas`, a bag of schema descriptions, and each description +//! is complete. +//! +//! The schemas, their entries and their prefixes are those of PDF Association TechNote 0009, "XMP +//! Extension Schemas in PDF/A-1" (2008), section 4, for PDF/A-2 6.6.2.3.3: +//! - a schema (`pdfaSchema`, 4.3) has `schema`, `namespaceURI` and `prefix`; its `property` and +//! `valueType` lists are optional. TechNote 0009, written for PDF/A-1, calls `schema` an +//! optional description; PDF/A-2 requires it, as its conformance tests show; +//! - a property (`pdfaProperty`, 4.4) has `name`, `valueType`, `category` and `description`; +//! - a value type (`pdfaType`, 4.5) has `type`, `namespaceURI`, `prefix`, `description` and +//! `field`. TechNote 0009 calls `field` optional; PDF/A-2 requires it, as its conformance tests +//! show; +//! - a field (`pdfaField`, 4.6) has `name`, `valueType` and `description`; +//! - each of the five namespaces is written with its own prefix (`pdfaExtension`, `pdfaSchema`, +//! `pdfaProperty`, `pdfaType`, `pdfaField`): "Unlike predefined XMP schemas, the namespace +//! prefixes are not only preferred, but required" (4.1). +//! +//! An entry counts as present written as an element or as an attribute of the description. An +//! entry that names something is empty when it holds no value: a namespace URI or prefix, a +//! property, value type or field name, a value type reference, a property category. TechNote 0009 +//! asks for names that are valid XML names (4.4, 4.6) and a category that is internal or external +//! (4.4), and an empty prefix or namespace binds nothing. The human-readable descriptions +//! (`pdfaSchema:schema` and the `description` entries) are Text and may be empty. + +use pdfcraft_cos::ObjRef; +use roxmltree::{Document, Node}; + +const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const EXTENSION: &str = "http://www.aiim.org/pdfa/ns/extension/"; +const SCHEMA: &str = "http://www.aiim.org/pdfa/ns/schema#"; +const PROPERTY: &str = "http://www.aiim.org/pdfa/ns/property#"; +const TYPE: &str = "http://www.aiim.org/pdfa/ns/type#"; +const FIELD: &str = "http://www.aiim.org/pdfa/ns/field#"; + +/// Each container namespace with its required prefix (TechNote 0009, 4.1). +const PREFIXES: [(&str, &str); 5] = + [(EXTENSION, "pdfaExtension"), (SCHEMA, "pdfaSchema"), (PROPERTY, "pdfaProperty"), (TYPE, "pdfaType"), (FIELD, "pdfaField")]; + +/// How many findings a document's packets are read for. A document past this many has plainly +/// failed the clause, and listing every one of a hostile packet's descriptions helps nobody. +const MAX_FINDINGS: usize = 1000; +/// How much of a name the packet gives (a namespace URI, a property) a finding repeats. +const MAX_SHOWN_CHARS: usize = 200; + +/// What the extension schema descriptions of a document break of 6.6.2.3.3, in the order the +/// metadata streams and their descriptions come. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Findings { + /// Entries descriptions lack. + pub(crate) missing: Vec, + /// Description namespaces written with another prefix, each once per stream and prefix. + pub(crate) prefixes: Vec, + /// `pdfaExtension:schemas` containers that aren't one `rdf:Bag` (TechNote 0009, 4.2: "bag + /// Schema"). + pub(crate) not_bag: Vec, +} + +/// A description without an entry it needs, or with the entry empty. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Missing { + /// The metadata stream. + pub(crate) object: ObjRef, + /// The schema the description is in: its `pdfaSchema:namespaceURI`, or `#n` for the n-th + /// schema of its container when it has none. + pub(crate) schema: String, + /// The property, value type or field description, when it is one of those and not the schema + /// description itself: its `pdfaProperty:name` or `pdfaType:type`, a field as `type/name`, or + /// `#n` for the n-th of its list when it has no name. + pub(crate) item: Option, + /// The entry it lacks or holds empty, as `prefix:name`. + pub(crate) entry: String, + /// Whether the entry is there but empty, rather than absent. + pub(crate) empty: bool, +} + +/// A description namespace written with another prefix than its own. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Prefix { + pub(crate) object: ObjRef, + pub(crate) namespace: &'static str, + /// The prefix it needs. + pub(crate) own: &'static str, + /// The prefix it is written with, empty when it is written as the default namespace. + pub(crate) written: String, +} + +/// A `pdfaExtension:schemas` container that isn't one `rdf:Bag`. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct NotBag { + pub(crate) object: ObjRef, + /// The elements it holds instead, by their names as the packet writes them, comma-separated; + /// empty when it holds none. + pub(crate) found: String, +} + +/// Where a description is: its stream, its schema and, below the schema, its own name. +struct Place { + object: ObjRef, + schema: String, + item: Option, +} + +impl Findings { + /// Adds what the packet `doc`, from the metadata stream `object`, breaks. + pub(crate) fn add(&mut self, object: ObjRef, doc: &Document<'_>) { + let containers = doc.descendants().filter(|n| is(n, EXTENSION, "schemas")); + for container in containers { + self.prefixes_in(object, doc, &container); + let children: Vec> = container.children().filter(Node::is_element).collect(); + if !matches!(children.as_slice(), [bag] if is(bag, RDF, "Bag")) && self.room() { + let found = children.iter().take(MAX_SHOWN_CHARS).map(|c| written_name(doc, c)).collect::>().join(", "); + self.not_bag.push(NotBag { object, found: shown(&found) }); + } + for (n, schema) in items(&container).iter().enumerate() { + let id = value(schema, SCHEMA, "namespaceURI").unwrap_or_else(|| position(n)); + let at = |item: Option| Place { object, schema: id.clone(), item }; + self.need(&at(None), schema, SCHEMA, "pdfaSchema", &["schema", "namespaceURI", "prefix"]); + let properties = entries(schema, SCHEMA, "property").flat_map(|e| items(&e)); + for (n, property) in properties.enumerate() { + let name = value(&property, PROPERTY, "name").unwrap_or_else(|| position(n)); + self.need(&at(Some(name)), &property, PROPERTY, "pdfaProperty", &["name", "valueType", "category", "description"]); + } + let value_types = entries(schema, SCHEMA, "valueType").flat_map(|e| items(&e)); + for (n, value_type) in value_types.enumerate() { + let name = value(&value_type, TYPE, "type").unwrap_or_else(|| position(n)); + self.need(&at(Some(name.clone())), &value_type, TYPE, "pdfaType", &["type", "namespaceURI", "prefix", "description", "field"]); + for (n, field) in entries(&value_type, TYPE, "field").flat_map(|e| items(&e)).enumerate() { + let field_name = value(&field, FIELD, "name").unwrap_or_else(|| position(n)); + self.need(&at(Some(format!("{name}/{field_name}"))), &field, FIELD, "pdfaField", &["name", "valueType", "description"]); + } + } + } + } + } + + /// Whether another finding may be recorded (see [`MAX_FINDINGS`]). + fn room(&self) -> bool { + self.missing.len().saturating_add(self.prefixes.len()).saturating_add(self.not_bag.len()) < MAX_FINDINGS + } + + /// Notes the entries of `ns` in `names` that the description `d`, at `at`, lacks, or holds + /// empty when they are entries that name something (see [`NAMING`]). + fn need(&mut self, at: &Place, d: &Node<'_, '_>, ns: &str, prefix: &str, names: &[&str]) { + for name in names { + let element = entry(d, ns, name); + let attribute = d.attribute((ns, *name)); + let empty = match (element, attribute) { + (None, None) => false, + (Some(e), _) => NAMING.contains(name) && is_empty(&e), + (None, Some(a)) => NAMING.contains(name) && a.trim().is_empty(), + }; + if (empty || (element.is_none() && attribute.is_none())) && self.room() { + let Place { object, schema, item } = at; + let entry = format!("{prefix}:{name}"); + self.missing.push(Missing { object: *object, schema: schema.clone(), item: item.clone(), entry, empty }); + } + } + } + + /// Notes container namespaces written in `container` (itself included) with another prefix + /// than their own. + fn prefixes_in(&mut self, object: ObjRef, doc: &Document<'_>, container: &Node<'_, '_>) { + let text = doc.input_text(); + for n in container.descendants().filter(Node::is_element) { + let element = (n.tag_name().namespace(), text.get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default()); + let attributes = n.attributes().map(|a| (a.namespace(), text.get(a.range_qname()).unwrap_or_default())); + for (ns, qname) in std::iter::once(element).chain(attributes) { + let Some(&(namespace, own)) = PREFIXES.iter().find(|(uri, _)| Some(*uri) == ns) else { continue }; + let written = shown(written_prefix(qname)); + if written != own + && self.room() + && !self.prefixes.iter().any(|p| p.object == object && p.namespace == namespace && p.written == written) + { + self.prefixes.push(Prefix { object, namespace, own, written }); + } + } + } + } +} + +/// The entries that name something, which an empty value leaves without a name: namespace URIs +/// and prefixes, property, value type and field names, value type references and the property +/// category. +const NAMING: [&str; 6] = ["namespaceURI", "prefix", "name", "type", "valueType", "category"]; + +/// Whether the element entry `e` holds no value: no element, no text but white space, and no +/// `rdf:resource` or `rdf:value` attribute with a value. +fn is_empty(e: &Node<'_, '_>) -> bool { + let blank = |v: Option<&str>| v.is_none_or(|v| v.trim().is_empty()); + !e.children().any(|c| c.is_element() || (c.is_text() && c.text().is_some_and(|t| !t.trim().is_empty()))) + && blank(e.attribute((RDF, "resource"))) + && blank(e.attribute((RDF, "value"))) +} + +/// `#n` for the 0-based position `n`, counted from 1. +fn position(n: usize) -> String { + format!("#{}", n.saturating_add(1)) +} + +/// At most [`MAX_SHOWN_CHARS`] of `s`, cut at a char boundary, with `…` when cut. +fn shown(s: &str) -> String { + let mut out: String = s.chars().take(MAX_SHOWN_CHARS).collect(); + if out.len() < s.len() { + out.push('…'); + } + out +} + +/// The text of the entry `name` of `ns` in the description `d`, written as an element or an +/// attribute, trimmed and [`shown`]; `None` when it is absent or blank. +fn value(d: &Node<'_, '_>, ns: &str, name: &str) -> Option { + let text = entry(d, ns, name).and_then(|e| e.text()).or_else(|| d.attribute((ns, name)))?.trim(); + (!text.is_empty()).then(|| shown(text)) +} + +/// The name of the element `n` as the packet writes it, prefix included. +fn written_name<'i>(doc: &Document<'i>, n: &Node<'_, '_>) -> &'i str { + qualified_name(doc.input_text().get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default()) +} + +/// The qualified name the text of a start tag (after its `<`) or of an attribute begins with. +fn qualified_name(text: &str) -> &str { + text.split(|c: char| c.is_whitespace() || c == '>' || c == '/' || c == '=').next().unwrap_or_default() +} + +/// The prefix of the qualified name `text` begins with, empty when it has none. +fn written_prefix(text: &str) -> &str { + qualified_name(text).split_once(':').map_or("", |(p, _)| p) +} + +fn is(n: &Node<'_, '_>, ns: &str, name: &str) -> bool { + n.is_element() && n.tag_name().namespace() == Some(ns) && n.tag_name().name() == name +} + +/// Every element entry `name` of `ns` in the description `d`: a list written twice is read twice. +fn entries<'a, 'i>(d: &Node<'a, 'i>, ns: &'a str, name: &'a str) -> impl Iterator> { + d.children().filter(move |c| is(c, ns, name)) +} + +/// The element entry `name` of `ns` in the description `d`. +fn entry<'a, 'i>(d: &Node<'a, 'i>, ns: &str, name: &str) -> Option> { + d.children().find(|c| is(c, ns, name)) +} + +/// The descriptions in the array `e` holds: each item's `rdf:parseType="Resource"` node or +/// `rdf:Description`. +fn items<'a, 'i>(e: &Node<'a, 'i>) -> Vec> { + let Some(array) = e.children().find(|c| is(c, RDF, "Bag") || is(c, RDF, "Seq") || is(c, RDF, "Alt")) else { return Vec::new() }; + array.children().filter(|i| is(i, RDF, "li")).map(|li| li.children().find(|c| is(c, RDF, "Description")).unwrap_or(li)).collect() +} diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index 3563a671db..cd24e59a13 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -4,7 +4,10 @@ //! The types come from ISO 16684-1:2011 (XMP Part 1) clause 8 for the Dublin Core, XMP Basic and //! XMP Rights Management namespaces, and from XMP Specification Part 2 (Additional Properties) for //! the Media Management, Basic Job Ticket, Paged-Text, Dynamic Media, Adobe PDF, Photoshop and -//! Camera Raw namespaces, with the structures they use. A value is read as RDF/XML serializes it +//! Camera Raw namespaces, with the structures they use. The TIFF, EXIF and Additional EXIF (`aux`) +//! namespaces, which the current Part 2 leaves to CIPA DC-010, come from the September 2005 XMP +//! Specification that PDF/A-2 cites: "EXIF Schemas" (pages 58–70) and "EXIF Schema Value Types" +//! (pages 83–87). A value is read as RDF/XML serializes it //! (ISO 16684-1 7.9): a simple value as text or an attribute, an array as `rdf:Seq`, `rdf:Bag` or //! `rdf:Alt`, a structure as `rdf:parseType="Resource"`, an `rdf:Description` or attributes, and a //! qualified value through its `rdf:value` (element or attribute). Every metadata stream of the @@ -12,7 +15,8 @@ //! //! The simple types are checked by their lexical form (ISO 16684-1 8.2.1): Boolean is `True` or //! `False`; Integer an optionally signed run of digits; Real a decimal; Date one of the six forms -//! of 8.2.1.2; Rational two integers around a `/`, as Part 2's `xmpDM:scale` example writes it. +//! of 8.2.1.2; Rational two integers around a `/`, as Part 2's `xmpDM:scale` example writes it and +//! the 2005 edition defines it (page 84); GPSCoordinate `DDD,MM,SSk` or `DDD,MM.mmk` (page 85). //! The other simple types are text. A field a structure doesn't define, and a property a //! namespace doesn't define, are left to the clauses about them. @@ -43,6 +47,8 @@ enum Ty { Boolean, Date, Rational, + /// `DDD,MM,SSk` or `DDD,MM.mmk`, `k` one of N, S, E, W (2005 edition, page 85). + GpsCoordinate, /// An alternative array of text items, each with an `xml:lang` (ISO 16684-1 8.2.2.4). LangAlt, Seq(&'static Ty), @@ -203,6 +209,19 @@ const ANCESTOR: &[Field] = &[(PHOTOSHOP, "AncestorID", Text)]; /// Part 2 Table 32. const LAYER: &[Field] = &[(PHOTOSHOP, "LayerName", Text), (PHOTOSHOP, "LayerText", Text)]; +const EXIF: &str = "http://ns.adobe.com/exif/1.0/"; + +/// Flash (2005 edition, page 85). +const FLASH: &[Field] = + &[(EXIF, "Fired", Boolean), (EXIF, "Return", Integer), (EXIF, "Mode", Integer), (EXIF, "Function", Boolean), (EXIF, "RedEyeMode", Boolean)]; +/// OECF/SFR (2005 edition, page 86). +const OECF_SFR: &[Field] = + &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Names", Ty::Seq(&Text)), (EXIF, "Values", Ty::Seq(&Rational))]; +/// CFAPattern (2005 edition, page 87). +const CFA_PATTERN: &[Field] = &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Values", Ty::Seq(&Integer))]; +/// DeviceSettings (2005 edition, page 87). +const DEVICE_SETTINGS: &[Field] = &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Settings", Ty::Seq(&Text))]; + const SCHEMAS: &[Schema] = &[ // ISO 16684-1 Table 4. Schema { @@ -452,6 +471,126 @@ const SCHEMAS: &[Schema] = &[ ("WhiteBalance", Text), ], }, + // 2005 edition, "EXIF Schema for TIFF Properties", pages 58–60. A Closed Choice of Integer is an + // Integer. + Schema { + uri: "http://ns.adobe.com/tiff/1.0/", + prefix: "tiff", + properties: &[ + ("ImageWidth", Integer), + ("ImageLength", Integer), + ("BitsPerSample", Ty::Seq(&Integer)), + ("Compression", Integer), + ("PhotometricInterpretation", Integer), + ("Orientation", Integer), + ("SamplesPerPixel", Integer), + ("PlanarConfiguration", Integer), + ("YCbCrSubSampling", Ty::Seq(&Integer)), + ("YCbCrPositioning", Integer), + ("XResolution", Rational), + ("YResolution", Rational), + ("ResolutionUnit", Integer), + ("TransferFunction", Ty::Seq(&Integer)), + ("WhitePoint", Ty::Seq(&Rational)), + ("PrimaryChromaticities", Ty::Seq(&Rational)), + ("YCbCrCoefficients", Ty::Seq(&Rational)), + ("ReferenceBlackWhite", Ty::Seq(&Rational)), + ("DateTime", Date), + ("ImageDescription", LangAlt), + ("Make", Text), + ("Model", Text), + ("Software", Text), + ("Artist", Text), + ("Copyright", LangAlt), + ], + }, + // 2005 edition, "EXIF Schema for EXIF-specific Properties", pages 60–69. + Schema { + uri: EXIF, + prefix: "exif", + properties: &[ + ("ExifVersion", Text), + ("FlashpixVersion", Text), + ("ColorSpace", Integer), + ("ComponentsConfiguration", Ty::Seq(&Integer)), + ("CompressedBitsPerPixel", Rational), + ("PixelXDimension", Integer), + ("PixelYDimension", Integer), + ("UserComment", LangAlt), + ("RelatedSoundFile", Text), + ("DateTimeOriginal", Date), + ("DateTimeDigitized", Date), + ("ExposureTime", Rational), + ("FNumber", Rational), + ("ExposureProgram", Integer), + ("SpectralSensitivity", Text), + ("ISOSpeedRatings", Ty::Seq(&Integer)), + ("OECF", Ty::Struct("OECF/SFR", OECF_SFR)), + ("ShutterSpeedValue", Rational), + ("ApertureValue", Rational), + ("BrightnessValue", Rational), + ("ExposureBiasValue", Rational), + ("MaxApertureValue", Rational), + ("SubjectDistance", Rational), + ("MeteringMode", Integer), + ("LightSource", Integer), + ("Flash", Ty::Struct("Flash", FLASH)), + ("FocalLength", Rational), + ("SubjectArea", Ty::Seq(&Integer)), + ("FlashEnergy", Rational), + ("SpatialFrequencyResponse", Ty::Struct("OECF/SFR", OECF_SFR)), + ("FocalPlaneXResolution", Rational), + ("FocalPlaneYResolution", Rational), + ("FocalPlaneResolutionUnit", Integer), + ("SubjectLocation", Ty::Seq(&Integer)), + ("ExposureIndex", Rational), + ("SensingMethod", Integer), + ("FileSource", Integer), + ("SceneType", Integer), + ("CFAPattern", Ty::Struct("CFAPattern", CFA_PATTERN)), + ("CustomRendered", Integer), + ("ExposureMode", Integer), + ("WhiteBalance", Integer), + ("DigitalZoomRatio", Rational), + ("FocalLengthIn35mmFilm", Integer), + ("SceneCaptureType", Integer), + ("GainControl", Integer), + ("Contrast", Integer), + ("Saturation", Integer), + ("Sharpness", Integer), + ("DeviceSettingDescription", Ty::Struct("DeviceSettings", DEVICE_SETTINGS)), + ("SubjectDistanceRange", Integer), + ("ImageUniqueID", Text), + ("GPSVersionID", Text), + ("GPSLatitude", Ty::GpsCoordinate), + ("GPSLongitude", Ty::GpsCoordinate), + ("GPSAltitudeRef", Integer), + ("GPSAltitude", Rational), + ("GPSTimeStamp", Date), + ("GPSSatellites", Text), + ("GPSStatus", Text), + ("GPSMeasureMode", Text), + ("GPSDOP", Rational), + ("GPSSpeedRef", Text), + ("GPSSpeed", Rational), + ("GPSTrackRef", Text), + ("GPSTrack", Rational), + ("GPSImgDirectionRef", Text), + ("GPSImgDirection", Rational), + ("GPSMapDatum", Text), + ("GPSDestLatitude", Ty::GpsCoordinate), + ("GPSDestLongitude", Ty::GpsCoordinate), + ("GPSDestBearingRef", Text), + ("GPSDestBearing", Rational), + ("GPSDestDistanceRef", Text), + ("GPSDestDistance", Rational), + ("GPSProcessingMethod", Text), + ("GPSAreaInformation", Text), + ("GPSDifferential", Integer), + ], + }, + // 2005 edition, "EXIF Schema for Additional EXIF Properties", page 69. + Schema { uri: "http://ns.adobe.com/exif/1.0/aux/", prefix: "aux", properties: &[("Lens", Text), ("SerialNumber", Text)] }, ]; /// Calls `f` with every metadata stream among the indirect objects `objs` (ISO 32000-1 14.3.2) that @@ -535,8 +674,8 @@ fn written(qname: &str, local: &str) -> String { format!("{out}:{local}") } -/// `ty` as the XMP Specification writes value types: `Integer`, `Lang Alt`, `seq Rational`, a -/// structure by its type's name, such as `Dimensions`. +/// `ty` as the XMP Specification writes value types: `Integer`, `GPSCoordinate`, `Lang Alt`, +/// `seq Rational`, a structure by its type's name, such as `Flash`. fn named(ty: Ty) -> String { let array = |kind: &str, item: &Ty| format!("{kind} {}", named(*item)); match ty { @@ -547,6 +686,7 @@ fn named(ty: Ty) -> String { Boolean => "Boolean".into(), Date => "Date".into(), Rational => "Rational".into(), + Ty::GpsCoordinate => "GPSCoordinate".into(), LangAlt => "Lang Alt".into(), Ty::Seq(item) => array("seq", item), Ty::Bag(item) => array("bag", item), @@ -608,7 +748,7 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { return blank(e) && blank(&n) && simple(ty, v) && predefined_ok(&n, deeper); } match ty { - Ty::Text | Ty::Guid | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational => { + Ty::Text | Ty::Guid | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational | Ty::GpsCoordinate => { // `rdf:resource` makes the value a URI, which only a text type takes. // The element is then empty. if e.attribute((RDF, "resource")).is_some() { @@ -716,12 +856,25 @@ fn simple(ty: Ty, v: &str) -> bool { Ty::Boolean => matches!(v, "True" | "False"), Ty::Date => date(v), Ty::Rational => v.split_once('/').is_some_and(|(n, d)| integer(n) && integer(d)), + Ty::GpsCoordinate => gps_coordinate(v), Ty::Text | Ty::Guid => true, Ty::OneOf(types) => types.iter().any(|t| simple(*t, v)), _ => false, } } +/// `DDD,MM,SSk` or `DDD,MM.mmk`: degrees, minutes and seconds or minutes with any number of +/// fractional digits, then N, S, E or W (2005 edition, page 85). +fn gps_coordinate(v: &str) -> bool { + let Some(v) = v.strip_suffix(['N', 'S', 'E', 'W']) else { return false }; + let Some((degrees, rest)) = v.split_once(',') else { return false }; + let minutes_ok = match rest.split_once(',') { + Some((minutes, seconds)) => digits(minutes) && digits(seconds), + None => rest.split_once('.').is_some_and(|(m, f)| digits(m) && digits(f)), + }; + digits(degrees) && minutes_ok +} + fn digits(s: &str) -> bool { !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) } diff --git a/crates/preflight/tests/generation_repairs.rs b/crates/preflight/tests/generation_repairs.rs index c57ef141d2..68c2f2b663 100644 --- a/crates/preflight/tests/generation_repairs.rs +++ b/crates/preflight/tests/generation_repairs.rs @@ -4,7 +4,9 @@ use pdfcraft_preflight::{Level, convert}; use std::sync::Arc; fn image_file(damaged: bool) -> Arc> { - let mut bytes = b"%PDF-1.7\n".to_vec(); + // A PDF/A-2 header (6.1.2), so that Save as PDF/A has no header to rewrite and only the + // repaired references decide whether the next save is full. + let mut bytes = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); let bodies = [ "<< /Type /Catalog /Pages 2 0 R /HeaderImage 5 1 R /RowImage 5 0 R /StaleImage 5 2 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", diff --git a/crates/preflight/tests/known_answers.rs b/crates/preflight/tests/known_answers.rs index 23d503dc62..bb92e3dd83 100644 --- a/crates/preflight/tests/known_answers.rs +++ b/crates/preflight/tests/known_answers.rs @@ -22,7 +22,8 @@ use pdfcraft_preflight::{Level, convert, declared, icc, verify}; /// A document from hand-written objects and extra trailer entries (copied from the crate's unit /// tests). fn build(objs: &[&str], trailer_extra: &str) -> Document { - let mut out = b"%PDF-1.7\n".to_vec(); + // ISO 19005-2 6.1.2: the header line is followed by a comment of four bytes above 127. + let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { offsets.push(out.len()); @@ -33,7 +34,10 @@ fn build(objs: &[&str], trailer_extra: &str) -> Document { for o in offsets { out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + // ISO 19005-2 6.1.3: the trailer names a file identifier; a test about another clause gets one + // unless it gives its own. + let id = if trailer_extra.contains("/ID") { "" } else { "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]" }; + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {id} {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); Document::open(std::sync::Arc::new(out)).unwrap() } diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index e78b047f18..9a9e3c015b 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -55,10 +55,6 @@ pub struct DocInfo { pub keywords: Option, pub creator: Option, pub producer: Option, - /// `/Info /CreationDate` and `/ModDate` for display ("2026-10-01 12:30", see [`pretty_date`]); - /// a value that isn't a PDF date is shown as written. - pub created: Option, - pub modified: Option, /// Custom document properties: `/Info` entries with a key the spec does not name and a text /// value, as (name, text), in the dictionary's order (at most 256). pub custom: Vec<(String, String)>, diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index e120f11c3a..11c2299a58 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -188,14 +188,6 @@ impl<'a> Inspector<'a> { info.keywords = self.text(d, b"Keywords"); info.creator = self.text(d, b"Creator"); info.producer = self.text(d, b"Producer"); - // Dates are text strings only (ISO 32000-2 §7.9.4), as the engine reads them after an - // edit (`pdfcraft_organize::info`): a name value is no date. - let date = |key: &[u8]| match self.resolve(d.get(key).ok()?) { - Object::String(..) => self.text(d, key).map(|s| pretty_date(&s)), - _ => None, - }; - info.created = date(b"CreationDate"); - info.modified = date(b"ModDate"); const STANDARD: [&[u8]; 9] = [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"]; // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`, diff --git a/crates/render/src/inspect/resources.rs b/crates/render/src/inspect/resources.rs index dbed6d13fb..75c65c6bf9 100644 --- a/crates/render/src/inspect/resources.rs +++ b/crates/render/src/inspect/resources.rs @@ -14,7 +14,7 @@ const MAX_LAYER_GROUP_ENTRIES: usize = 4096; /// The configuration's auto state (`/AS`): at most this many usage application dictionaries, /// `/OCGs` entries across them, and `/Category` names in each are read. The renderer's -/// (vendored hayro-interpret patch (76)) have the same values. +/// (vendored hayro-interpret patch (103)) have the same values. const MAX_AUTO_STATE_APPLICATIONS: usize = 256; const MAX_AUTO_STATE_GROUPS: usize = 4096; const MAX_AUTO_STATE_CATEGORIES: usize = 16; @@ -139,7 +139,7 @@ impl<'a> Inspector<'a> { /// The state the configuration's auto state (`/AS`) gives each group it manages for the View /// event (ISO 32000-2 §8.11.4.4, Table 101), which overrides `/BaseState`, `/ON` and `/OFF` /// as the document opens. Keep in step with the renderer's (vendored hayro-interpret patch - /// (76), `apply_view_auto_state`): each usage application with `/Event /View` and `/View` in + /// (103), `apply_view_auto_state`): each usage application with `/Event /View` and `/View` in /// its `/Category` recommends the `/ViewState` of each group in its `/OCGs` whose `/Usage` /// gives one; a group is on only if all its recommendations are. Groups with none are absent. /// Other categories (`/Zoom`, `/User`, `/Language`) give no recommendation here. diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index eac128e4a7..9070441ccc 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -729,31 +729,6 @@ const CUSTOM_INFO: &[u8] = b"%PDF-1.7 trailer << /Root 1 0 R /Info 4 0 R >> %%EOF"; -/// One page whose `/Info` is `info` (the dictionary's contents). -fn with_info(info: &str) -> Vec { - format!( - "%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj -3 0 obj << /Type /Page /Parent 2 0 R >> endobj -4 0 obj << {info} >> endobj -trailer << /Root 1 0 R /Info 4 0 R >> -%%EOF" - ) - .into_bytes() -} - -#[test] -fn creation_and_modification_dates_are_read_for_document_properties() { - let info = inspect(Arc::new(with_info("/CreationDate (D:20240105093000+01'00') /ModDate (D:20261031174500Z)")), None).expect("opens"); - assert_eq!((info.created.as_deref(), info.modified.as_deref()), (Some("2024-01-05 09:30"), Some("2026-10-31 17:45"))); - // Not a PDF date: shown as written. Empty, a number, a name and absent: no date. - let info = inspect(Arc::new(with_info("/CreationDate (last Tuesday) /ModDate ()")), None).expect("opens"); - assert_eq!((info.created.as_deref(), info.modified.as_deref()), (Some("last Tuesday"), None)); - let info = inspect(Arc::new(with_info("/CreationDate 20240105 /ModDate /D:20240105093000Z")), None).expect("opens"); - assert_eq!((info.created, info.modified), (None, None)); -} - #[test] fn custom_document_properties_are_listed_apart_from_the_standard_ones() { let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens"); diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 648c086475..cd7e95a7cb 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -274,6 +274,9 @@ pub enum RenderWarning { /// A colour could not be computed (a colour space that cannot be read, or a Separation or /// DeviceN tint transform that cannot be evaluated) and was painted in a fallback colour. ColourFallback, + /// A Type 3 font without the `/Widths` it requires (ISO 32000-2 Table 110) was used; its + /// glyphs were spaced by the widths their glyph procedures give. + Type3WidthsMissing, } /// Counters for work performed by a renderer or render pool. @@ -484,6 +487,7 @@ fn warning_settings(settings: &InterpreterSettings, warnings: Arc RenderWarning::ContentTruncated, InterpreterWarning::ColorFallback => RenderWarning::ColourFallback, + InterpreterWarning::Type3WidthsMissing => RenderWarning::Type3WidthsMissing, InterpreterWarning::UnsupportedFont | InterpreterWarning::ImageDecodeFailure => return, }; let mut warnings = lock(&target); diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 4b8084721b..1f68122af5 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -5690,6 +5690,22 @@ fn a_font_whose_post_version_has_swapped_halves_is_still_used() { assert_eq!(alternate([0, 0, 0, 2]), alternate([0, 2, 0, 0]), "and also with swapped halves"); } +/// A font dictionary whose `/Subtype` is missing or the empty name is read as a Type 1 font, +/// so its `/BaseFont` picks the face (vendored hayro-interpret patch 94). `/Subtype` is required +/// (ISO 32000-2 Table 109), but such a `/Courier-Bold` was drawn in the Helvetica fallback, +/// shifting its columns (PDFBOX-1988). +#[test] +fn a_font_without_a_subtype_is_drawn_by_its_base_font() { + let render = |subtype: &str, base_font: &str| { + let resources = format!("<< /Font << /F1 << /Type /Font {subtype} /BaseFont /{base_font} >> >> >>"); + render_first_page(image_cache_pdf("BT /F1 20 Tf 5 25 Td (iiiiii) Tj ET", &resources, &[])).rgba.to_vec() + }; + let courier = render("/Subtype /Type1", "Courier-Bold"); + assert_ne!(courier, render("/Subtype /Type1", "Helvetica"), "Courier Bold and Helvetica differ"); + assert_eq!(render("/Subtype /", "Courier-Bold"), courier, "an empty /Subtype"); + assert_eq!(render("", "Courier-Bold"), courier, "no /Subtype"); +} + /// A page with no `/MediaBox`, its own or inherited, is US Letter (612 × 792 pt), as other /// readers draw it and as the page model and inspector already sized it; it was A4 (vendored /// hayro-syntax patch 44; ISO 32000-2 Table 31 makes the box required and gives no default; @@ -6605,6 +6621,94 @@ trailer << /Root 1 0 R >> assert!(p != [255, 255, 255, 255] && p[3] == 255, "the triangles are drawn: {p:?}"); } +/// A rainbow of tensor-product patches (two quarter rings per band) painted as a pattern +/// through a rectangle that is just the rainbow's box. Every patch is 722 triangles, and +/// across a narrow curved band they are thin and lie at every angle, so their boxes hold +/// many times their area: walking those boxes spent the visit budget (eight per pixel of the +/// rectangle, four per triangle) before the last patches, and the inner bands were left white +/// (through the page-sized rectangle, or with `sh`, the same mesh drew whole). The sampler +/// now walks only the columns of each row a triangle can reach. Vendored hayro-interpret +/// patch 99. +#[test] +fn thin_patches_of_a_mesh_in_a_tight_path_are_all_painted() { + const K: f64 = 0.5523; // a quarter circle's Bézier handles, as a fraction of the radius + const BANDS: [[u8; 3]; 6] = [[255, 0, 0], [255, 128, 0], [255, 255, 0], [0, 200, 0], [0, 0, 255], [128, 0, 255]]; + // The stream order of a tensor patch's points (column i, row j), ISO 32000-2 8.7.4.5.8. + const ORDER: [(usize, usize); 16] = + [(0, 0), (0, 1), (0, 2), (0, 3), (1, 3), (2, 3), (3, 3), (3, 2), (3, 1), (3, 0), (2, 0), (1, 0), (1, 1), (1, 2), (2, 2), (2, 1)]; + // Bands 20/6 pt wide from radius 50 inwards, centred on (100, 100) by the pattern matrix; + // 16-bit coordinates decoded onto -64..64. + let width = 20.0 / BANDS.len() as f64; + let mut data = Vec::new(); + for (band, colour) in BANDS.iter().enumerate() { + let (outer, inner) = (50.0 - band as f64 * width, 50.0 - (band + 1) as f64 * width); + for side in [1.0, -1.0] { + data.push(0u8); + for (i, j) in ORDER { + let rho = inner + (outer - inner) * j as f64 / 3.0; + let (x, y) = [(side * rho, 0.0), (side * rho, K * rho), (side * K * rho, rho), (0.0, rho)][i]; + for v in [x, y] { + data.extend_from_slice(&(((v + 64.0) / 128.0 * 65535.0).round() as u16).to_be_bytes()); + } + } + for _ in 0..4 { + data.extend_from_slice(colour); + } + } + } + let render = |rect: &str| { + let content = format!("/Pattern cs /P0 scn {rect} re f"); + let mut pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Pattern << /P0 6 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +6 0 obj << /Type /Pattern /PatternType 2 /Shading 5 0 R /Matrix [1 0 0 1 100 100] >> endobj +5 0 obj << /ShadingType 7 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [-64 64 -64 64 0 1 0 1 0 1] /Length {} >> stream +", + content.len(), + data.len() + ) + .into_bytes(); + pdf.extend_from_slice(&data); + pdf.extend_from_slice( + b" +endstream endobj +trailer << /Root 1 0 R >> +%%EOF +", + ); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.5, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (300, 300)); + page + }; + // At 45° on either side, the middle of the outer and of the inner band, in device pixels + // (scale 1.5, y down). + let at = |rho: f64, side: f64| { + let d = rho * std::f64::consts::FRAC_1_SQRT_2; + (((100.0 + side * d) * 1.5) as usize, ((100.0 - d) * 1.5) as usize) + }; + for (what, rect) in [("the rainbow's box", "50 100 100 50"), ("the page", "0 0 200 200")] { + let page = render(rect); + for (band, rho) in [(0, 50.0 - width / 2.0), (BANDS.len() - 1, 30.0 + width / 2.0)] { + for side in [1.0, -1.0] { + let (x, y) = at(rho, side); + let got = rgb_at(&page.rgba, 300, x, y); + let want = BANDS[band]; + assert!( + got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 8), + "through {what}, band {band} at ({x}, {y}): got {got:?}, want {want:?}" + ); + } + } + } +} + /// Two triangles of a mesh that share an edge cover it together. The sampler tested each /// pixel's corner, and only strictly inside a triangle, so the diagonal two triangles share /// was left white wherever it ran through the samples, and so was a pixel row or column @@ -7964,6 +8068,329 @@ trailer << /Root 1 0 R >> } } +/// A page of `/ImageMask` stencils painted with a tiling pattern: the 4 × 4 stencil inks a ring +/// (its centre 2 × 2 samples are `1`, unpainted under the default `/Decode [0 1]`), filled with +/// a red coloured pattern. +fn patterned_stencil_page(media: (u32, u32), content: &str) -> RenderedPage { + let tile = "1 0 0 rg 0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents 4 0 R /Resources << /XObject << /Msk 5 0 R >> /Pattern << /P 6 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ImageMask true /Filter /ASCIIHexDecode /Length 9 >> stream +00606000> +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + media.0, + media.1, + content.len(), + tile.len() + ); + let page = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + page +} + +/// A stencil mask filled with a pattern shows the pattern where the stencil inks and nothing +/// elsewhere: not in its unpainted samples, and not around it, also when it is drawn smaller +/// than its samples and lands on whole device pixels. +#[test] +fn a_patterned_stencil_mask_paints_only_where_it_inks() { + // Pixel rows run top down: PDF (x, y) is pixel (x, 200 - y). + let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[(((200 - y) * p.width + x) * 4) as usize..][..3].to_vec(); + let page = patterned_stencil_page((200, 200), "/Pattern cs /P scn q 40 0 0 40 20 20 cm /Msk Do Q q 3 0 0 3 100.4 100.4 cm /Msk Do Q"); + assert_eq!(px(&page, 25, 25), [255, 0, 0], "the ring is red"); + assert_eq!(px(&page, 40, 40), [255, 255, 255], "the stencil's unpainted centre stays white"); + for (x, y) in [(15, 40), (65, 40), (40, 15), (40, 65)] { + assert_eq!(px(&page, x, y), [255, 255, 255], "outside the stencil at ({x}, {y})"); + } + assert!((100..104).any(|x| px(&page, x, 101) != [255, 255, 255]), "the shrunk stencil is drawn"); + for edge in [97, 98, 106, 107] { + for along in 97..=107 { + assert_eq!(px(&page, edge, along), [255, 255, 255], "beside the shrunk stencil at ({edge}, {along})"); + assert_eq!(px(&page, along, edge), [255, 255, 255], "above or below the shrunk stencil at ({along}, {edge})"); + } + } +} + +/// A patterned stencil mask under a soft mask is drawn at the soft mask's opacity once, as a +/// rectangle filled with the pattern beside it is: the stencil's coverage, which cuts the pattern +/// to the stencil's shape, is not masked a second time. +#[test] +fn a_patterned_stencil_mask_under_a_soft_mask_is_masked_once() { + let content = "/S gs /Pattern cs /P scn q 40 0 0 40 20 20 cm /Msk Do Q 120 20 40 40 re f"; + let tile = "1 0 0 rg 0 0 10 10 re f"; + let group = "0.5 g 0 0 200 200 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Msk 5 0 R >> /Pattern << /P 6 0 R >> /ExtGState << /S << /SMask << /S /Luminosity /G 7 0 R >> >> >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ImageMask true /Filter /ASCIIHexDecode /Length 9 >> stream +00606000> +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceGray >> /Length {} >> stream +{group} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + tile.len(), + group.len() + ); + let page = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + // Pixel rows run top down: PDF (x, y) is pixel (x, 200 - y). + let px = |x: u32, y: u32| page.rgba[(((200 - y) * page.width + x) * 4) as usize..][..3].to_vec(); + let (ring, rect) = (px(25, 25), px(140, 40)); + assert!(rect != [255, 255, 255] && rect != [255, 0, 0], "the rectangle is half masked: {rect:?}"); + assert!(ring.iter().zip(&rect).all(|(a, b)| a.abs_diff(*b) <= 2), "stencil {ring:?}, rectangle {rect:?}"); + assert_eq!(px(40, 40), [255, 255, 255], "the stencil's unpainted centre stays white"); +} + +/// A patterned stencil mask in a Type 3 glyph is shrunk by area averaging, as it is outside +/// one (#4800): the glyph's own image rules are for its images, not for the coverage that cuts +/// the pattern. Here a one-column stroke of an 8-column stencil, drawn 4 pixels wide, is a +/// half-covered red; nearest-neighbour would read columns 1, 3, 5 and 7 and miss it. +#[test] +fn a_patterned_stencil_mask_in_a_type3_glyph_keeps_thin_strokes() { + let content = "BT /Pattern cs /P scn /T3 1 Tf 0 0 Td (a) Tj ET"; + let glyph = "400 0 0 0 400 100 d1 q 400 0 0 100 0 0 cm /Im0 Do Q"; + let tile = "1 0 0 rg 0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 4 1] /Contents 4 0 R /Resources << /Font << /T3 6 0 R >> /Pattern << /P 8 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 1 /ImageMask true /Filter /ASCIIHexDecode /Length 3 >> stream +DF> +endstream endobj +6 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 400 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /a 7 0 R >> /Encoding << /Type /Encoding /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [400] /Resources << /XObject << /Im0 5 0 R >> >> >> endobj +7 0 obj << /Length {} >> stream +{glyph} +endstream endobj +8 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + glyph.len(), + tile.len() + ); + let page = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (4, 1)); + let px = page.rgba.as_chunks::<4>().0.to_vec(); + let [r, g, b, _] = px.get(1).copied().unwrap_or_default(); + assert!(r >= 254 && g.abs_diff(128) <= 1 && b == g, "half-covered red over white, got {px:?}"); + assert_eq!(px.get(3), Some(&[255, 255, 255, 255]), "{px:?}"); +} + +/// pdf.js issue 8078 paints 73,047 small stencil masks with one tiling pattern. Each was drawn +/// through a canvas-sized luminance mask (a full-page render and composite per stencil), which +/// took minutes for the page; a stencil now costs what its rectangle covers. Here 5,000 +/// patterned stencils on a 1000 × 2000 page. What the page draws offscreen is counted (hayro +/// patch 30) rather than timed, so the check is the same on every machine: the one pattern cell, +/// and not a page-sized pixmap per stencil. +#[test] +fn many_patterned_stencil_masks_draw_no_page_sized_masks() { + let mut content = String::from("/Pattern cs /P scn "); + for i in 0..5000u32 { + content.push_str(&format!("q 16 0 0 16 {} {} cm /Msk Do Q ", (i % 50) * 20, (i / 50) * 20)); + } + let before = hayro::offscreen_draws(); + let page = patterned_stencil_page((1000, 2000), &content); + let after = hayro::offscreen_draws(); + assert_eq!(after.tiling_cells - before.tiling_cells, 1, "one cell for the pattern's one setting"); + let pixels = after.pixels - before.pixels; + assert!(pixels < 1000 * 2000, "5,000 patterned stencils drew {pixels} pixels offscreen"); + // The last stencil sits at (980, 1980): its ring is red, its centre white. + let px = |x: u32, y: u32| page.rgba[(((2000 - y) * page.width + x) * 4) as usize..][..3].to_vec(); + assert_eq!(px(981, 1981), [255, 0, 0]); + assert_eq!(px(988, 1988), [255, 255, 255]); +} + +/// A coloured tiling pattern's cell is drawn once for all the fills of one setting (`scn`), as +/// counted by hayro patch 30: here 3,000 small squares filled with one pattern. Drawn again for +/// each fill, each cell was kept until the page was composited (pdf.js issue 8078 ran out of +/// memory that way, its 3.5 MB cell drawn for each of 73,047 stencils). The squares all show the +/// pattern in place. The same holds for a step so large that the fills show one cell, which is +/// drawn with a transparent border instead of repeated. +#[test] +fn fills_of_one_pattern_setting_share_its_cell() { + let mut content = String::from("/Pattern cs /P scn "); + for i in 0..3000u32 { + content.push_str(&format!("{} {} 4 4 re f ", (i % 60) * 5, (i / 60) * 5)); + } + // The cell is blue with a red lower-left quarter; at `/Matrix` scale 10 a cell is 100 units, + // so with a step of 10 red shows where both coordinates, modulo 100, are below 50. With a + // step of 100000 the one cell shows and nothing past it. + let cell = "0 0 1 rg 0 0 10 10 re f 1 0 0 rg 0 0 5 5 re f"; + for (step, past_the_first_cell) in [("10", [0, 0, 255]), ("100000", [255, 255, 255])] { + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Pattern << /P 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep {step} /YStep {step} /Matrix [10 0 0 10 0 0] /Resources << >> /Length {} >> stream +{cell} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + cell.len() + ); + let before = hayro::offscreen_draws(); + let page = render_first_page(pdf.into_bytes()); + let cells = hayro::offscreen_draws().tiling_cells - before.tiling_cells; + assert_eq!(cells, 1, "step {step}: cells drawn for 3,000 fills of one setting"); + // Pixel rows run top down: PDF (x, y) is pixel (x, 300 - y). + let px = |x: u32, y: u32| page.rgba[(((300 - y) * page.width + x) * 4) as usize..][..3].to_vec(); + // The first square, at (0, 0), and the last, at (295, 245). + assert_eq!(px(1, 1), [255, 0, 0], "step {step}"); + assert_eq!(px(296, 246), past_the_first_cell, "step {step}"); + } +} + +/// Fills interleaving the settings of two coloured patterns with cells of one size, and of one +/// of them at two cell scales, each show their own pattern in place: a cell kept for one setting +/// is not taken for another (hayro patch 30). `/P` is blue with a red lower-left quarter and `/Q` +/// green with a yellow one, both at `/Matrix` scale 10, so a cell is 100 units and its quarter +/// shows where both coordinates, modulo 100, are below 50. The form `/X`, drawn at scale 2, sets +/// `/P` in its own space, where a cell is 200 units, red where both page coordinates, modulo +/// 200, are below 100. Each of the six settings draws its cell once, for all its fills. +#[test] +fn fills_of_interleaved_pattern_settings_each_show_their_own_cell() { + let content = "/Pattern cs /P scn 60 60 4 4 re f 10 10 4 4 re f q 2 0 0 2 0 0 cm /X Do Q \ + /Q scn 110 10 4 4 re f 160 160 4 4 re f /P scn 210 210 4 4 re f q 2 0 0 2 0 0 cm /X Do Q \ + /Q scn 260 260 4 4 re f 110 110 4 4 re f"; + // In page space, squares at (60, 260) and (260, 60), where `/P` at scale 1 is blue. + let form = "/Pattern cs /P scn 30 130 2 2 re f 130 30 2 2 re f"; + let p = "0 0 1 rg 0 0 10 10 re f 1 0 0 rg 0 0 5 5 re f"; + let q = "0 1 0 rg 0 0 10 10 re f 1 1 0 rg 0 0 5 5 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Pattern << /P 5 0 R /Q 6 0 R >> /XObject << /X 7 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Matrix [10 0 0 10 0 0] /Resources << >> /Length {} >> stream +{p} +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Matrix [10 0 0 10 0 0] /Resources << >> /Length {} >> stream +{q} +endstream endobj +7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 150 150] /Resources << /Pattern << /P 5 0 R >> >> /Length {} >> stream +{form} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + p.len(), + q.len(), + form.len() + ); + let before = hayro::offscreen_draws(); + let page = render_first_page(pdf.into_bytes()); + assert_eq!(hayro::offscreen_draws().tiling_cells - before.tiling_cells, 6, "one cell per setting"); + // The middle of the 4 × 4 square at PDF (x, y); pixel rows run top down. + let px = |x: u32, y: u32| page.rgba[(((300 - 2 - y) * page.width + x + 2) * 4) as usize..][..3].to_vec(); + let (red, blue, green, yellow) = ([255, 0, 0], [0, 0, 255], [0, 255, 0], [255, 255, 0]); + for (x, y, colour, what) in [ + (60, 60, blue, "/P"), + (10, 10, red, "/P"), + (60, 260, red, "/P at scale 2"), + (260, 60, red, "/P at scale 2"), + (110, 10, yellow, "/Q"), + (160, 160, green, "/Q"), + (210, 210, red, "/P set again"), + (260, 260, green, "/Q set again"), + (110, 110, yellow, "/Q set again"), + ] { + assert_eq!(px(x, y), colour, "{what} at ({x}, {y})"); + } +} + +/// An uncoloured tiling pattern (`/PaintType 2`) takes the colour of each setting, so fills that +/// alternate red and green settings of it show both, each in place. Its cell also takes the +/// opacity the setting captured, which a patterned stencil mask's copy of the setting leaves out +/// (its transparency group applies it, hayro-interpret patch 46), so the cell drawn for one fill +/// of a setting is not the other's (hayro patch 30 keeps only coloured cells): a square and a +/// stencil painted with one half-opaque blue setting show the same half blue. +#[test] +fn an_uncoloured_pattern_shows_each_settings_colour() { + let content = "/C cs 1 0 0 /U scn 10 10 20 20 re f 0 1 0 /U scn 40 10 20 20 re f \ + 1 0 0 /U scn 70 10 20 20 re f 0 1 0 /U scn 100 10 20 20 re f \ + /H gs 0 0 1 /U scn 10 50 20 20 re f q 20 0 0 20 40 50 cm /Msk Do Q"; + let tile = "0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 150 100] /Contents 4 0 R /Resources << /ColorSpace << /C [/Pattern /DeviceRGB] >> /Pattern << /U 6 0 R >> /XObject << /Msk 5 0 R >> /ExtGState << /H << /ca 0.5 >> >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ImageMask true /Filter /ASCIIHexDecode /Length 9 >> stream +00000000> +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + tile.len() + ); + let page = render_first_page(pdf.into_bytes()); + // Pixel rows run top down: PDF (x, y) is pixel (x, 100 - y). + let px = |x: u32, y: u32| page.rgba[(((100 - y) * page.width + x) * 4) as usize..][..3].to_vec(); + for (x, colour) in [(20, [255, 0, 0]), (50, [0, 255, 0]), (80, [255, 0, 0]), (110, [0, 255, 0])] { + assert_eq!(px(x, 20), colour, "the square at x {x}"); + } + let (square, stencil) = (px(20, 60), px(50, 60)); + assert!(square[2] == 255 && square[0].abs_diff(128) <= 1 && square[1] == square[0], "half blue: {square:?}"); + assert!(square.iter().zip(&stencil).all(|(a, b)| a.abs_diff(*b) <= 2), "square {square:?}, stencil {stencil:?}"); +} + /// #2971: in a knockout transparency group (`/K true`) each element composites with the /// group's initial backdrop, not with the elements drawn before it in the group, over the /// element's shape. A blue square at 50 % opacity over a red one at 50 % shows only the blue @@ -8636,21 +9063,232 @@ fn decoded_images_serve_every_size_they_are_drawn_at() { let mut device = Draws::default(); interpret_page(page, &mut ctx, &mut device); assert_eq!(device.0, 5, "every draw of /{name} was handed its pixels"); - cache.decoded_image_decodes() + // (image decodes, mask decodes) + let masks = cache.decoded_mask_decodes(); + (cache.decoded_image_decodes() - masks, masks) }; - assert_eq!(decodes("A"), 1, "a Flate image decodes the same at every size"); - assert_eq!(decodes("B"), 5, "a JPX soft mask decodes for the size it is drawn at"); + assert_eq!(decodes("A"), (1, 0), "a Flate image decodes the same at every size"); + // The image decodes once for each of the five sizes, its JPX soft mask within it: only a mask + // averaged down (hayro-interpret patch 82) has a cache entry of its own. + assert_eq!(decodes("B"), (5, 0), "an image with a JPX soft mask decodes for the size it is drawn at"); +} + +/// A soft mask far larger than where its image is drawn is averaged down to that size and cached +/// there (vendored hayro-interpret patch 82): a 2 × 2 image whose mask is 34,862 × 4,332, drawn 40 +/// times on one slide (pdf.js `issue16263.pdf`), was decoded and drawn at full size each time and +/// the page took minutes. Drawn ten times at one size, the mask is now decoded once and handed +/// over at that size rounded up to a power of two; a mask no larger than twice its drawn size +/// keeps its own. +#[test] +fn a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once() { + use hayro::hayro_interpret::font::Glyph; + use hayro::hayro_interpret::hayro_syntax::Pdf; + use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page, + }; + use kurbo::{Affine, BezPath, Rect}; + use std::io::Write; + + /// Asks for every raster image at the size it is drawn at, as the renderer does, and keeps + /// the size of each alpha channel it was handed. + #[derive(Default)] + struct Draws(Vec<(u32, u32)>); + impl<'a> Device<'a> for Draws { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + let [a, b, c, d, ..] = transform.as_coeffs(); + let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32); + if let Image::Raster(r) = image { + r.with_rgba(|_, alpha| self.0.push(alpha.map_or((0, 0), |a| (a.width, a.height))), Some(target)); + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + let flate = |data: &[u8]| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let rgb = flate(&[0u8; 2 * 2 * 3]); + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /SMask 6 0 R /Length {} >>", + rgb.len() + ); + // `side` × `side` mask samples, drawn ten times at 20 × 20 points (device pixels at 72 dpi). + let draws = |side: u32| { + let alpha = flate(&(0..side * side).map(|i| (i % 256) as u8).collect::>()); + let mask = format!( + "<< /Type /XObject /Subtype /Image /Width {side} /Height {side} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>", + alpha.len() + ); + let content = (0..10).map(|i| format!("q 20 0 0 20 {} 0 cm /A Do Q", i * 22)).collect::>().join(" "); + let bytes = image_cache_pdf(&content, "<< /XObject << /A 5 0 R >> >>", &[(&image, &rgb), (&mask, &alpha)]); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 230.0, 30.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Draws::default(); + interpret_page(page, &mut ctx, &mut device); + (device.0, cache.decoded_image_decodes()) + }; + let (alphas, decodes) = draws(1000); + // Drawn 20 pixels square, averaged down to that rounded up to a power of two. + assert_eq!(alphas, vec![(32, 32); 10], "averaged down to the size it is drawn at"); + // One decode for the image and one for its mask (here no larger than the intermediate the + // averaged-down sizes are made from, so that is the mask itself), plus the averaged-down mask, + // all shared by the ten draws. + assert_eq!(decodes, 3, "decoded once"); + let (alphas, _) = draws(40); + assert_eq!(alphas, vec![(40, 40); 10], "no more than twice the drawn size: kept as it is"); +} + +/// The soft masks a small image is handed, drawn at each of `sizes` (width × height points at 72 +/// dpi): its `mask` (`width`, `height`, 8-bit samples) is larger than the 2 × 1 image, so it may +/// be averaged down (vendored hayro-interpret patch 82). Each mask is given as its size and +/// samples. +fn soft_masks_handed(width: u32, height: u32, mask: &[u8], sizes: &[(u32, u32)]) -> (Vec<(u32, u32, Vec)>, usize) { + soft_masks_handed_keyed(width, height, mask, sizes, ("Width", "Height")) +} + +/// `soft_masks_handed`, with the mask's dimensions under `keys` (`/W` and `/H`, say). +fn soft_masks_handed_keyed(width: u32, height: u32, mask: &[u8], sizes: &[(u32, u32)], keys: (&str, &str)) -> (Vec<(u32, u32, Vec)>, usize) { + use hayro::hayro_interpret::font::Glyph; + use hayro::hayro_interpret::hayro_syntax::Pdf; + use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page, + }; + use kurbo::{Affine, BezPath, Rect}; + use std::io::Write; + + #[derive(Default)] + struct Draws(Vec<(u32, u32, Vec)>); + impl<'a> Device<'a> for Draws { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + let [a, b, c, d, ..] = transform.as_coeffs(); + let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32); + if let Image::Raster(r) = image { + r.with_rgba(|_, alpha| self.0.extend(alpha.map(|a| (a.width, a.height, a.data))), Some(target)); + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let flate = |data: &[u8]| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let rgb = flate(&[0u8; 2 * 3]); + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /SMask 6 0 R /Length {} >>", + rgb.len() + ); + let alpha = flate(mask); + let mask_dict = format!( + "<< /Type /XObject /Subtype /Image /{} {width} /{} {height} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>", + keys.0, + keys.1, + alpha.len() + ); + let content = sizes.iter().map(|(w, h)| format!("q {w} 0 0 {h} 0 0 cm /A Do Q")).collect::>().join(" "); + let bytes = image_cache_pdf(&content, "<< /XObject << /A 5 0 R >> >>", &[(&image, &rgb), (&mask_dict, &alpha)]); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 400.0, 400.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Draws::default(); + interpret_page(page, &mut ctx, &mut device); + (device.0, cache.decoded_image_decodes()) +} + +/// An averaged-down soft mask (vendored hayro-interpret patch 82): +/// - takes each source sample into the target sample its centre falls in: seven columns +/// `0 0 0 255 0 0 0`, drawn three wide and so averaged to four, give `0 0 127 0` (by left edges, +/// `0 127 0 0`); +/// - is never sized past the samples a truncated stream decodes: a mask that says it is 100 rows +/// but holds 10, drawn 20 high, is handed no rows that no sample covers; +/// - is cached per drawn size, rounded up to a power of two: the same image drawn 20 and then 200 +/// points wide is handed its mask at 32 and then at its own 100, not the first draw's 32 again, +/// and drawn 20 and 21 wide it is handed one mask of 32 both times, whether the mask's +/// dimensions are under `/Width` and `/Height` or `/W` and `/H`; +/// - is made, at every size, from one intermediate mask at most 4096 on a side, so a mask wider +/// than that, drawn at four sizes, is decoded in full once and not four times. +#[test] +fn an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size() { + let (handed, _) = soft_masks_handed(7, 1, &[0, 0, 0, 255, 0, 0, 0], &[(3, 1)]); + assert_eq!(handed, vec![(4, 1, vec![0, 0, 127, 0])], "centres, not left edges"); + let (handed, _) = soft_masks_handed(100, 100, &[255; 100 * 10], &[(20, 20)]); + // 32 wide (20 rounded up to a power of two), and 10 rows, the ones decoded, not 32: all opaque. + assert_eq!(handed, vec![(32, 10, vec![255; 32 * 10])], "no uncovered rows"); + let sizes = |draws: &[(u32, u32)]| -> Vec<(u32, u32)> { + soft_masks_handed(100, 100, &[255; 100 * 100], draws).0.iter().map(|(w, h, _)| (*w, *h)).collect() + }; + assert_eq!(sizes(&[(20, 20), (200, 200)]), vec![(32, 32), (100, 100)], "each draw at its own size"); + assert_eq!(sizes(&[(20, 20), (21, 21)]), vec![(32, 32), (32, 32)], "draws a pixel apart share one"); + // The same with the mask's dimensions under the abbreviated `/W` and `/H`, which an image is + // read with too. + let (handed, _) = soft_masks_handed_keyed(100, 100, &[255; 100 * 100], &[(20, 20), (200, 200)], ("W", "H")); + let abbreviated: Vec<(u32, u32)> = handed.iter().map(|(w, h, _)| (*w, *h)).collect(); + assert_eq!(abbreviated, vec![(32, 32), (100, 100)], "each draw at its own size, under /W and /H"); + // Columns alternating 0 and 255, so every averaged-down sample is about half: within 8, as a + // sample covering 19 or 20 columns, say, holds 9 or 10 opaque ones. + let wide: Vec = (0..5000 * 2).map(|i| if i % 2 == 0 { 0 } else { 255 }).collect(); + let (handed, decodes) = soft_masks_handed(5000, 2, &wide, &[(20, 1), (40, 1), (80, 1), (160, 1)]); + let sizes: Vec<(u32, u32)> = handed.iter().map(|(w, h, _)| (*w, *h)).collect(); + assert_eq!(sizes, vec![(32, 1), (64, 1), (128, 1), (256, 1)]); + assert!(handed.iter().all(|(_, _, data)| data.iter().all(|&a| a.abs_diff(127) <= 8)), "{handed:?}"); + // The image at each of the four sizes, the 4096-wide intermediate once, and the mask at each + // of the four sizes made from it. Without the intermediate there are 8, each mask size a full + // decode. + assert_eq!(decodes, 9, "the full mask decoded once"); } // ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48). mod icc_profile_cache; +// A Type 3 glyph shown again decodes its procedure once (vendored hayro-interpret patch 87). +mod type3_proc_cache; + +// A picture that doesn't decode is decoded once however often it is drawn (vendored +// hayro-interpret patch 91). +mod failed_image_decodes; + // A page past its rasterizer coverage leaves the rest out and says so (vendored vello_cpu patch 1). mod coverage_budget; // A symbolic TrueType font draws through its (3, 0) cmap subtable first (#4156). mod symbolic_truetype_cmap; +// A TrueType font whose `head.indexToLocFormat` is out of range reads `loca` in the format its +// length gives (vendored hayro-interpret patch 83). +mod truetype_loca_format; + +// An Arial (#5889) or Times New Roman (#6449) the PDF doesn't embed draws the glyphs its codes +// name. +mod non_embedded_arial; + +// A Type 3 font without /Widths advances by its glyphs' d0/d1 widths (PDFBOX-2943). +mod type3_proc_widths; + +// A symbolic TrueType font in the standard Macintosh glyph order reads its codes as Mac OS Roman +// (vendored hayro-interpret patch 101). +mod symbolic_truetype_mac_roman_text; + /// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own. fn render_first_page(pdf: Vec) -> RenderedPage { let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); diff --git a/crates/render/src/raster/tests/failed_image_decodes.rs b/crates/render/src/raster/tests/failed_image_decodes.rs new file mode 100644 index 0000000000..bd0329ed31 --- /dev/null +++ b/crates/render/src/raster/tests/failed_image_decodes.rs @@ -0,0 +1,239 @@ +//! A picture that doesn't decode is decoded once however often a page draws it (vendored +//! hayro-interpret patch 91). The decoded-image cache kept only decodes that succeeded, so a +//! damaged picture, or a JPX or JBIG2 stream its decoder refuses, was decoded again at every +//! draw: a page drawing it a thousand times decoded it a thousand times. The failure is kept for +//! the same key now, and every draw still reports it as it did. + +use super::image_cache_pdf; +use hayro::hayro_interpret::font::Glyph; +use hayro::hayro_interpret::hayro_syntax::Pdf; +use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, InterpreterWarning, Paint, PathDrawMode, + SoftMask, TransformExt, interpret_page, +}; +use kurbo::{Affine, BezPath, Rect}; +use std::io::Write; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +/// What a page handed its device: the raster images with their pixels, those of them with a +/// mask, and the stencil masks with theirs. Every image is asked for at the size it is drawn +/// at, as the renderer does. +#[derive(Default)] +struct Draws { + rasters: usize, + masked: usize, + stencils: usize, +} + +impl<'a> Device<'a> for Draws { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + let [a, b, c, d, ..] = transform.as_coeffs(); + let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32); + match image { + Image::Raster(r) => r.with_rgba( + |_, alpha| { + self.rasters += 1; + self.masked += usize::from(alpha.is_some()); + }, + Some(target), + ), + Image::Stencil(s) => s.with_stencil(|_, _| self.stencils += 1, Some(target)), + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} +} + +/// What drawing `/name` five times at one size, and then the picture that decodes (`/G`) once +/// on the right, did: the decodes the cache ran, the image decode failures reported, and what +/// the device was handed. +struct Outcome { + decodes: usize, + failures: usize, + draws: Draws, +} + +fn draw_five_times(name: &str) -> Outcome { + draw_five_times_at(name, [20; 5]) +} + +/// [`draw_five_times`], drawing `/name` the five times at `sizes` (units square). +fn draw_five_times_at(name: &str, sizes: [u32; 5]) -> Outcome { + let flate = |data: &[u8]| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let pixels = flate(&(0..64 * 64 * 3).map(|i| (i % 251) as u8).collect::>()); + let image = |filter: &str, extra: &str, len: usize| { + format!( + "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /{filter} {extra}/Length {len} >>" + ) + }; + let good = image("FlateDecode", "", pixels.len()); + // A JPEG 2000 picture whose codestream is four zero bytes. + let bad_jpx = image("JPXDecode", "", 4); + // A picture whose soft mask is such a JPEG 2000 stream. + let bad_mask = image("FlateDecode", "/SMask 9 0 R ", pixels.len()); + // A stencil mask whose JBIG2 data is four zero bytes. + let bad_stencil = "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ImageMask true /Filter /JBIG2Decode /Length 4 >>"; + let jpx_mask = + "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode /Length 4 >>"; + let content = [0, 24, 48, 72, 96].iter().zip(sizes).map(|(x, s)| format!("q {s} 0 0 {s} {x} 10 cm /{name} Do Q")).collect::>().join(" ") + + " q 10 0 0 10 118 10 cm /G Do Q"; + let bytes = image_cache_pdf( + &content, + "<< /XObject << /G 5 0 R /J 6 0 R /S 7 0 R /M 8 0 R >> >>", + &[(&good, &pixels), (&bad_jpx, b"\0\0\0\0"), (&bad_mask, &pixels), (bad_stencil, b"\0\0\0\0"), (jpx_mask, b"\0\0\0\0")], + ); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let failures = Arc::new(AtomicUsize::new(0)); + let others = Arc::new(Mutex::new(Vec::new())); + let settings = InterpreterSettings { + warning_sink: { + let (failures, others) = (failures.clone(), others.clone()); + Arc::new(move |warning| match warning { + InterpreterWarning::ImageDecodeFailure => { + failures.fetch_add(1, Ordering::Relaxed); + } + other => others.lock().unwrap().push(format!("{other:?}")), + }) + }, + ..InterpreterSettings::default() + }; + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), settings); + let mut draws = Draws::default(); + interpret_page(page, &mut ctx, &mut draws); + assert!(others.lock().unwrap().is_empty(), "/{name} reported {:?}", others.lock().unwrap()); + Outcome { decodes: cache.decoded_image_decodes(), failures: failures.load(Ordering::Relaxed), draws } +} + +/// A JPEG 2000 picture that doesn't decode, drawn five times at one size, is decoded once, and +/// each of the five draws still reports the failure. +#[test] +fn a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn() { + let out = draw_five_times("J"); + assert_eq!(out.draws.rasters, 1, "only the picture after it drew"); + assert_eq!(out.decodes, 2, "decoded again at every draw"); + assert_eq!(out.failures, 5, "every draw reports the failure, as before"); +} + +/// A JPEG 2000 picture decodes at a reduced resolution for the size it is drawn at, so its +/// failure is kept for that size: drawn at two sizes, it is decoded once at each, also when it +/// comes back to the first size after the second. +#[test] +fn a_picture_that_does_not_decode_is_decoded_once_at_each_size() { + let out = draw_five_times_at("J", [20, 20, 10, 10, 20]); + assert_eq!(out.decodes, 3, "once at each of its two sizes, and the picture after it"); + assert_eq!(out.failures, 5, "every draw reports the failure"); +} + +/// A JBIG2 stencil mask that doesn't decode, drawn five times, is decoded once, and each draw +/// still reports the failure: it isn't the page's JBIG2 allowance that refused it, which a +/// later page would have afresh. +#[test] +fn a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn() { + let out = draw_five_times("M"); + assert_eq!((out.draws.stencils, out.draws.rasters), (0, 1), "only the picture after it drew"); + assert_eq!(out.decodes, 2, "decoded again at every draw"); + assert_eq!(out.failures, 5, "every draw reports the failure, as before"); +} + +/// A picture whose soft mask doesn't decode draws without it at every draw, decoded once, and +/// the mask's failure is reported once, as it was (the picture decodes; only its mask fails). +#[test] +fn a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once() { + let out = draw_five_times("S"); + assert_eq!((out.draws.rasters, out.draws.masked), (6, 0), "drawn five times without its mask, and the picture after it"); + assert_eq!(out.decodes, 2); + assert_eq!(out.failures, 1); +} + +/// A picture that decodes is still decoded once and handed to every draw. +#[test] +fn a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time() { + let out = draw_five_times("G"); + assert_eq!(out.draws.rasters, 6); + assert_eq!(out.decodes, 1); + assert_eq!(out.failures, 0); +} + +/// A JBIG2 picture the page's allowance left out isn't kept as a failure: the next page drawing +/// it starts with an allowance of its own and draws it, from the same cache. +#[test] +fn a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page() { + use super::{RenderWarning, black_jbig2_page, jbig2_segment}; + use crate::raster::{parse, render_page, take_warnings, warning_settings}; + use crate::{RenderConfig, RenderRequest, RequestKind}; + use hayro::RenderCache; + + let image = |number: u8, id: u8| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 62, &[], &[0, 0, 0, 0, id]); + let mut object = format!( + "{number} 0 obj << /Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", + stream.len() + ) + .into_bytes(); + object.extend_from_slice(&stream); + object.extend_from_slice(b"\nendstream endobj\n"); + object + }; + let page = |number: u8, content: u8| { + format!("{number} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents {content} 0 R /Resources << /XObject << /A 7 0 R /B 8 0 R >> >> >> endobj\n").into_bytes() + }; + let content = |number: u8, text: &str| format!("{number} 0 obj << /Length {} >> stream\n{text}\nendstream endobj\n", text.len()).into_bytes(); + let mut pdf = + b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n".to_vec(); + pdf.extend(page(3, 5)); + pdf.extend(page(4, 6)); + // The first page spends its allowance on A and has nothing left for B; the second draws A, + // decoded already, and then B. + pdf.extend(content(5, "q 20 0 0 40 0 0 cm /A Do Q q 20 0 0 40 20 0 cm /B Do Q")); + pdf.extend(content(6, "q 20 0 0 40 0 0 cm /A Do Q q 20 0 0 40 20 0 cm /B Do Q")); + pdf.extend(image(7, 1)); + pdf.extend(image(8, 2)); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + let pdf = parse(&Arc::new(pdf), None).expect("parses"); + /// A page's pixels and the warnings it reported. + type Drawn = (Vec, Vec); + let black = |(rgba, _): &Drawn, x: usize| match &rgba[((20 * 40 + x) * 4)..][..4] { + [0, 0, 0, 255] => true, + [255, 255, 255, 255] => false, + other => panic!("neither an image nor the page: {other:?}"), + }; + // Both pages, or the first alone, through one cache, as a render worker keeps it. + let pages = |work: u64, count: usize| { + let config = RenderConfig { jbig2_work: Some(work), ..RenderConfig::default() }; + let cache = RenderCache::new(); + (0..count) + .map(|page| { + let warnings = Arc::new(Mutex::new(Vec::new())); + let settings = warning_settings(&config.settings(), warnings.clone()); + let req = RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }; + let (_, _, rgba, _) = render_page(&pdf, &cache, &settings, &config, req).expect("renders"); + (rgba[..].to_vec(), take_warnings(&warnings)) + }) + .collect::>() + }; + // The smallest allowance that draws A on the first page. A larger allowance draws at least as + // much, so a binary search over the first page alone finds it in about ten draws. + let draws_a = |work: u64| pages(work, 1).first().is_some_and(|first| black(first, 10)); + let candidates: Vec = (1..1_000).collect(); + let one = candidates.get(candidates.partition_point(|work| !draws_a(*work))).copied().expect("a small image is cheap"); + let Ok([first, second]) = <[Drawn; 2]>::try_from(pages(one, 2)) else { panic!("two pages") }; + assert_eq!((black(&first, 10), black(&first, 30)), (true, false), "the first page had enough for A alone"); + assert!(first.1.contains(&RenderWarning::ContentTruncated), "{:?}", first.1); + assert!(black(&second, 10) && black(&second, 30), "A, and B left out of the first page, draw on the second"); + assert!(second.1.is_empty(), "{:?}", second.1); +} diff --git a/crates/render/src/raster/tests/non_embedded_arial.rs b/crates/render/src/raster/tests/non_embedded_arial.rs new file mode 100644 index 0000000000..42a990638a --- /dev/null +++ b/crates/render/src/raster/tests/non_embedded_arial.rs @@ -0,0 +1,150 @@ +//! An Arial or a Times New Roman the PDF doesn't embed draws the glyphs its codes name (vendored +//! hayro-interpret patches 76 and 88, #5889, #6449). Word and PDFMaker write such a font as a +//! Type 0 `CIDFontType2` with `Identity-H`, often without `/ToUnicode`, so each code is a glyph +//! index in the author's font. Both number their first 258 glyphs in the standard Macintosh order +//! of the TrueType `post` table (`L` is 47, `o` 82, `w` 90). The standard substitute numbers its +//! glyphs another way: "Low Power" drew as "@ck 'Dck Yf", and "Salarié(e) du régime général" as +//! "Salari²*g+f w't²i lo g'ì²p²tcn". +//! +//! Each document is drawn twice, once as such a file has it and once with a `/ToUnicode` that +//! names each code's character, which draws through the substitute's glyph for that character. +//! The two must be the same pixels. + +use super::render_first_page; + +/// "Low É" as Arial's glyph indexes: L, o, w, space, Eacute. +const LOW_E_ACUTE: [(u16, char); 5] = [(47, 'L'), (82, 'o'), (90, 'w'), (3, ' '), (101, 'É')]; + +struct Font<'a> { + /// The `/BaseFont` of both the Type 0 font and its descendant. + base_font: &'a str, + /// The descendant's `/Subtype`. + subtype: &'a str, + /// `true`: the codes are 1, 2, … and a `/CIDToGIDMap` stream gives each its glyph in the author's font. + /// `false`: `/CIDToGIDMap /Identity`, and the codes are the glyph indexes themselves. + mapped: bool, + to_unicode: bool, +} + +/// A page showing [`LOW_E_ACUTE`] at 40 pt in `font`, which has no font program. +fn pdf(font: &Font<'_>) -> Vec { + let codes: Vec = if font.mapped { (1..=LOW_E_ACUTE.len() as u16).collect() } else { LOW_E_ACUTE.iter().map(|(gid, _)| *gid).collect() }; + let hex: String = codes.iter().map(|code| format!("{code:04X}")).collect(); + let content = format!("BT /F1 40 Tf 5 15 Td <{hex}> Tj ET"); + let bfchar: String = codes.iter().zip(LOW_E_ACUTE).map(|(code, (_, c))| format!("<{code:04X}> <{:04X}>\n", u32::from(c))).collect(); + let cmap = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap +/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def +/CMapName /Adobe-Identity-UCS def /CMapType 2 def +1 begincodespacerange <0000> endcodespacerange +{} beginbfchar +{bfchar}endbfchar +endcmap CMapName currentdict /CMap defineresource pop end end", + codes.len() + ); + // CID 0 is .notdef; CIDs 1… give the glyphs in order. + let cid_to_gid: Vec = std::iter::once(0).chain(LOW_E_ACUTE.iter().map(|(gid, _)| *gid)).flat_map(u16::to_be_bytes).collect(); + let Font { base_font, subtype, .. } = font; + let to_unicode = if font.to_unicode { " /ToUnicode 8 0 R" } else { "" }; + let map = if font.mapped { "9 0 R" } else { "/Identity" }; + + let mut out = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 220 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /{base_font} /Encoding /Identity-H /DescendantFonts [6 0 R]{to_unicode} >> endobj +6 0 obj << /Type /Font /Subtype /{subtype} /BaseFont /{base_font} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> + /FontDescriptor 7 0 R /CIDToGIDMap {map} /DW 1000 >> endobj +7 0 obj << /Type /FontDescriptor /FontName /{base_font} /Flags 32 /FontBBox [-665 -325 2000 1040] /ItalicAngle 0 /Ascent 728 /Descent -210 /CapHeight 716 /StemV 80 >> endobj +8 0 obj << /Length {} >> stream +{cmap} +endstream endobj +9 0 obj << /Length {} >> stream +", + content.len(), + cmap.len(), + cid_to_gid.len() + ) + .into_bytes(); + out.extend_from_slice(&cid_to_gid); + out.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + out +} + +/// `font` drawn without and with a `/ToUnicode`; the second must draw ink. +fn without_and_with_unicode(font: Font<'_>) -> (Vec, Vec) { + let without = render_first_page(pdf(&Font { to_unicode: false, ..font })); + let with = render_first_page(pdf(&Font { to_unicode: true, ..font })); + assert!(with.rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[0] < 128), "{}: the control must draw ink", font.base_font); + (without.rgba.to_vec(), with.rgba.to_vec()) +} + +/// Each Arial face, as Windows exporters name it and as Word names a face it doesn't embed, draws +/// "Low É" from its glyph indexes, with `/CIDToGIDMap /Identity` and with a map stream. The name +/// is read without regard to case, `MT` included. +#[test] +fn an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name() { + for base_font in [ + "ArialMT", + "Arial-BoldMT", + "Arial-ItalicMT", + "Arial-BoldItalicMT", + "Arial", + "Arial,Bold", + "Arial,BoldItalic", + "ABCDEF+Arial-BoldMT", + "arialmt", + "Arial-Boldmt", + ] { + for mapped in [false, true] { + let (without, with) = without_and_with_unicode(Font { base_font, subtype: "CIDFontType2", mapped, to_unicode: false }); + assert!(without == with, "{base_font} (CIDToGIDMap {}): the codes draw \"Low É\"", if mapped { "stream" } else { "/Identity" }); + } + } +} + +/// Each Times New Roman face, as Windows exporters name it and as Word names a face it doesn't +/// embed (`TimesNewRoman,Bold` in #6449), draws "Low É" from its glyph indexes. +#[test] +fn a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name() { + for base_font in [ + "TimesNewRomanPSMT", + "TimesNewRomanPS-BoldMT", + "TimesNewRomanPS-ItalicMT", + "TimesNewRomanPS-BoldItalicMT", + "TimesNewRoman", + "TimesNewRoman,Bold", + "TimesNewRoman,Italic", + "TimesNewRoman,BoldItalic", + "WFXLUF+TimesNewRoman,Bold", + ] { + for mapped in [false, true] { + let (without, with) = without_and_with_unicode(Font { base_font, subtype: "CIDFontType2", mapped, to_unicode: false }); + assert!(without == with, "{base_font} (CIDToGIDMap {}): the codes draw \"Low É\"", if mapped { "stream" } else { "/Identity" }); + } + } +} + +/// Only the faces of Arial and Times New Roman are read in that order: Arial Narrow, Arial Black, +/// Arial Unicode MS, Times and Times New Roman MT Std are other fonts, and a `CIDFontType0` font's +/// CIDs aren't TrueType glyph indexes. +#[test] +fn codes_of_other_fonts_are_not_read_in_the_macintosh_order() { + for (base_font, subtype) in [ + ("ArialNarrow", "CIDFontType2"), + ("ArialNarrow-Bold", "CIDFontType2"), + ("Arial-Black", "CIDFontType2"), + ("ArialUnicodeMS", "CIDFontType2"), + ("ArialMT", "CIDFontType0"), + ("Times-Bold", "CIDFontType2"), + ("TimesNewRomanMTStd", "CIDFontType2"), + ("TimesNewRoman,Bold", "CIDFontType0"), + ] { + let (without, with) = without_and_with_unicode(Font { base_font, subtype, mapped: false, to_unicode: false }); + assert!(without != with, "{base_font} ({subtype}) is not read in the Macintosh order"); + } +} diff --git a/crates/render/src/raster/tests/symbolic_truetype_mac_roman_text.rs b/crates/render/src/raster/tests/symbolic_truetype_mac_roman_text.rs new file mode 100644 index 0000000000..87bcd6e4e0 --- /dev/null +++ b/crates/render/src/raster/tests/symbolic_truetype_mac_roman_text.rs @@ -0,0 +1,281 @@ +//! A symbolic TrueType font with no `/Encoding`, no `/ToUnicode` and no glyph names reads a code +//! as Mac OS Roman when its (1, 0) `cmap` subtable selects the glyph the code draws and that glyph +//! sits where the standard Macintosh glyph order puts the code's Mac OS Roman character (vendored +//! hayro-interpret patch 101; ISO 32000-2 §9.6.5.4: single bytes "look up the associated glyph +//! descriptions" in the (1, 0) subtable, and a (3, 0) subtable maps "the single-byte codes in the +//! (1, 0) subtable" prepended with 0xF0). pdf.js's issue2017r.pdf has such a font; its text read as +//! WinAnsi, `ˆ` for `à`. +//! +//! The fonts are built here, in memory: [`GLYPHS`] glyphs, those the tests show filled squares, no +//! `post` table, and the `cmap` subtables each test asks for. No font file is involved. + +use super::*; + +/// Glyphs in each font: past the standard Macintosh order's `quoteright` (183). +const GLYPHS: u16 = 200; + +/// The standard Macintosh order's glyphs for `agrave`, `eacute`, `endash` and `quoteright`. +const AGRAVE: u8 = 106; +const EACUTE: u8 = 112; +const ENDASH: u8 = 178; +const QUOTERIGHT: u8 = 183; + +fn be16(out: &mut Vec, n: u16) { + out.extend_from_slice(&n.to_be_bytes()); +} + +fn be32(out: &mut Vec, n: u32) { + out.extend_from_slice(&n.to_be_bytes()); +} + +/// A (1, 0) format 0 subtable mapping each code to its glyph. +fn mac_subtable(map: &[(u8, u8)]) -> Vec { + let mut out = Vec::new(); + for n in [0u16, 262, 0] { + be16(&mut out, n); // format, length, language + } + let mut ids = [0u8; 256]; + for &(code, glyph) in map { + ids[usize::from(code)] = glyph; + } + out.extend(ids); + out +} + +/// A (3, 0) format 4 subtable mapping each code at 0xF000 to its glyph, one segment each. +fn windows_subtable(map: &[(u8, u8)]) -> Vec { + let segments: Vec<(u16, u16)> = map.iter().map(|&(code, glyph)| (0xF000 | u16::from(code), u16::from(glyph))).chain([(0xFFFF, 0)]).collect(); + let count = segments.len() as u16; + let mut out = Vec::new(); + for n in [4u16, 16 + 8 * count, 0, 2 * count, 2, 0, 0] { + be16(&mut out, n); // format, length, language, segCountX2, searchRange, entrySelector, rangeShift + } + for &(code, _) in &segments { + be16(&mut out, code); // endCode + } + be16(&mut out, 0); // reservedPad + for &(code, _) in &segments { + be16(&mut out, code); // startCode + } + for &(code, glyph) in &segments { + be16(&mut out, if code == 0xFFFF { 1 } else { glyph.wrapping_sub(code) }); // idDelta + } + for _ in &segments { + be16(&mut out, 0); // idRangeOffset + } + out +} + +/// A TrueType font of [`GLYPHS`] glyphs, the `inked` ones squares, whose (3, 0) subtable maps +/// `windows` and whose (1, 0) subtable, if any, maps `mac`. A `named` font has a version 2.0 `post` +/// table naming its `inked` glyphs `bullet0`, `bullet1` and so on. +fn font(inked: &[u8], mac: Option<&[(u8, u8)]>, windows: &[(u8, u8)], named: bool) -> Vec { + // One contour, the square 0..1000 on both axes. + let mut square = Vec::new(); + for n in [1u16, 0, 0, 1000, 1000] { + be16(&mut square, n); + } + be16(&mut square, 3); // the contour's last point + be16(&mut square, 0); // no instructions + square.extend([1u8; 4]); // four on-curve points, two-byte coordinates + for dx in [0i16, 1000, 0, -1000] { + square.extend_from_slice(&dx.to_be_bytes()); + } + for dy in [0i16, 0, 1000, 0] { + square.extend_from_slice(&dy.to_be_bytes()); + } + let mut glyf = Vec::new(); + let mut loca = Vec::new(); + for glyph in 0..GLYPHS { + be16(&mut loca, (glyf.len() / 2) as u16); + if inked.iter().any(|g| u16::from(*g) == glyph) { + glyf.extend_from_slice(&square); + } + } + be16(&mut loca, (glyf.len() / 2) as u16); + + let mut head = vec![0; 54]; + head[0..4].copy_from_slice(&[0, 1, 0, 0]); + head[12..16].copy_from_slice(&0x5f0f_3cf5u32.to_be_bytes()); + head[18..20].copy_from_slice(&1000u16.to_be_bytes()); + head[40..42].copy_from_slice(&1000i16.to_be_bytes()); + head[42..44].copy_from_slice(&1000i16.to_be_bytes()); + let mut hhea = vec![0; 36]; + hhea[0..4].copy_from_slice(&[0, 1, 0, 0]); + hhea[4..6].copy_from_slice(&1000i16.to_be_bytes()); + hhea[34..36].copy_from_slice(&GLYPHS.to_be_bytes()); + let mut hmtx = Vec::new(); + for _ in 0..GLYPHS { + be16(&mut hmtx, 1000); + be16(&mut hmtx, 0); + } + let mut maxp = vec![0, 1, 0, 0]; + be16(&mut maxp, GLYPHS); + maxp.resize(32, 0); + + let subtables: Vec<((u16, u16), Vec)> = + mac.map(|m| ((1, 0), mac_subtable(m))).into_iter().chain([((3, 0), windows_subtable(windows))]).collect(); + let mut cmap = Vec::new(); + be16(&mut cmap, 0); + be16(&mut cmap, subtables.len() as u16); + let mut offset = 4 + 8 * subtables.len() as u32; + for ((platform, encoding), bytes) in &subtables { + be16(&mut cmap, *platform); + be16(&mut cmap, *encoding); + be32(&mut cmap, offset); + offset += bytes.len() as u32; + } + for (_, bytes) in &subtables { + cmap.extend(bytes); + } + + let mut tables = + vec![(*b"cmap", cmap), (*b"glyf", glyf), (*b"head", head), (*b"hhea", hhea), (*b"hmtx", hmtx), (*b"loca", loca), (*b"maxp", maxp)]; + if named { + let mut post = vec![0, 2, 0, 0]; + post.resize(32, 0); + be16(&mut post, GLYPHS); + for glyph in 0..GLYPHS { + // Index 258 and on are the table's own names, in order. + let own = inked.iter().position(|g| u16::from(*g) == glyph); + be16(&mut post, own.map_or(0, |i| 258 + i as u16)); + } + for i in 0..inked.len() { + let name = format!("bullet{i}"); + post.push(name.len() as u8); + post.extend(name.bytes()); + } + tables.push((*b"post", post)); + } + let mut out = vec![0, 1, 0, 0]; + be16(&mut out, tables.len() as u16); + for n in [64u16, 2, 48] { + be16(&mut out, n); + } + let mut payload = Vec::new(); + let mut offset = 12 + 16 * tables.len(); + for (tag, bytes) in tables { + out.extend(tag); + let mut padded = bytes.clone(); + padded.resize(bytes.len().div_ceil(4) * 4, 0); + let sum = padded.as_chunks::<4>().0.iter().fold(0u32, |s, b| s.wrapping_add(u32::from_be_bytes(*b))); + be32(&mut out, sum); + be32(&mut out, offset as u32); + be32(&mut out, bytes.len() as u32); + offset += padded.len(); + payload.extend(padded); + } + out.extend(payload); + out +} + +/// The text extracted from a page that shows `codes` in `font`, marked symbolic and with no +/// `/Encoding` and no `/ToUnicode`. +fn extracted(font: &[u8], codes: &[u8]) -> String { + extracted_with(font, codes, "AAAAAA+Sym", "") +} + +/// [`extracted`] with the font dictionary's `/BaseFont` named `base_font` and the `extra` entries. +fn extracted_with(font: &[u8], codes: &[u8], base_font: &str, extra: &str) -> String { + let hex: String = codes.iter().map(|c| format!("{c:02X}")).collect(); + let content = format!("BT /F1 30 Tf 5 5 Td <{hex}> Tj ET").into_bytes(); + let widths = vec!["1000"; 256].join(" "); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 80 40] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(), content.clone(), b"\nendstream".to_vec()].concat(), + format!("<< /Type /Font /Subtype /TrueType /BaseFont /{base_font} /FirstChar 0 /LastChar 255 /Widths [{widths}] /FontDescriptor 6 0 R {extra}>>").into_bytes(), + b"<< /Type /FontDescriptor /FontName /AAAAAA+Sym /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 /CapHeight 1000 /StemV 80 /FontFile2 7 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", font.len()).into_bytes(), font.to_vec(), b"\nendstream".to_vec()].concat(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend(format!("{} 0 obj\n", i + 1).into_bytes()); + pdf.extend(object); + pdf.extend(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes()); + for offset in offsets { + pdf.extend(format!("{offset:010} 00000 n \n").into_bytes()); + } + pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let out = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.text.map(|t| t.plain_text()).unwrap_or_default() +} + +#[test] +fn a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman() { + // As issue2017r.pdf's font: code c and 0xF000 + c select the glyph the standard Macintosh + // order gives Mac OS Roman c. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, false); + assert_eq!(extracted(&font, &[0x88, 0x8E]), "àé", "Mac OS Roman 0x88 is à and 0x8E é"); +} + +#[test] +fn winansi_codes_of_a_font_in_the_macintosh_order_read_as_before() { + // A Windows subset in the same order: code 0x92 draws `quoteright`, WinAnsi 0x92, where Mac OS + // Roman 0x92 is `iacute`. + let map = [(0x92, QUOTERIGHT), (0x96, ENDASH)]; + let font = font(&[ENDASH, QUOTERIGHT], Some(&map), &map, false); + assert_eq!(extracted(&font, &[0x92, 0x96]), "\u{2019}\u{2013}", "WinAnsi, not Mac OS Roman's íñ"); +} + +#[test] +fn a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before() { + let font = font(&[AGRAVE, EACUTE], None, &[(0x88, AGRAVE), (0x8E, EACUTE)], false); + assert_eq!(extracted(&font, &[0x88, 0x8E]), "ˆŽ", "with no (1, 0) subtable the codes read as WinAnsi"); +} + +#[test] +fn a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read() { + // The (3, 0) subtable is used first (#4156); a (1, 0) one that disagrees doesn't say what the + // drawn glyphs are. + let font = font(&[AGRAVE, EACUTE], Some(&[(0x88, EACUTE), (0x8E, AGRAVE)]), &[(0x88, AGRAVE), (0x8E, EACUTE)], false); + assert_eq!(extracted(&font, &[0x88, 0x8E]), "ˆŽ", "the codes read as WinAnsi, as before"); +} + +#[test] +fn a_font_that_names_its_glyphs_is_not_read_by_their_order() { + // A Wingdings subset draws code 0x83 with glyph 101, `Eacute` in the order, and names it + // `threesans`: a font with glyph names needn't keep the order. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, true); + assert_eq!(extracted(&font, &[0x88, 0x8E]), "ˆŽ", "the codes read as WinAnsi, as before"); +} + +#[test] +fn a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading() { + // Symbol and ZapfDingbats have built-in encodings that aren't Latin (ISO 32000-2 §9.6.6.1), so their codes get + // no implicit Mac OS Roman reading, as the editor's `unicode_map` gives them none. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, false); + for name in ["ZapfDingbats", "AAAAAA+Symbol"] { + let text = extracted_with(&font, &[0x88, 0x8E], name, ""); + assert_eq!(text, "ˆŽ", "{name}: the codes read as WinAnsi, as before"); + } +} + +#[test] +fn differences_naming_notdef_clear_the_mac_roman_reading_for_its_code() { + // `/Differences` without a base encoding: code 0x8E names `.notdef`, and code 0x88 keeps the Mac OS Roman reading. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, false); + let text = extracted_with(&font, &[0x88, 0x8E], "AAAAAA+Sym", "/Encoding << /Differences [142 /.notdef] >> "); + assert_eq!(text, "à", "0x8E reads as nothing"); +} + +#[test] +fn a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off() { + // An unknown `/Encoding` name is read as the built-in encoding, but it names a base encoding all the same, so the + // editor gives it no Mac OS Roman reading and neither does the renderer: the codes read as WinAnsi, as before. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, false); + assert_eq!(extracted_with(&font, &[0x88, 0x8E], "AAAAAA+Sym", "/Encoding /NoSuchEncoding "), "ˆŽ"); +} diff --git a/crates/render/src/raster/tests/truetype_loca_format.rs b/crates/render/src/raster/tests/truetype_loca_format.rs new file mode 100644 index 0000000000..aa668317ff --- /dev/null +++ b/crates/render/src/raster/tests/truetype_loca_format.rs @@ -0,0 +1,147 @@ +//! A TrueType font whose `head.indexToLocFormat` holds neither 0 nor 1 draws through the `loca` +//! format its table is in (vendored hayro-interpret patch 83). The OpenType `head` table allows +//! only 0 (short offsets) and 1 (long); the glyph reader took anything but 1 as short, so a font +//! whose field reads 256 (pdf.js `issue2537r.pdf`) had its long offsets read as short ones, and +//! "LINE UP" drew as boxes. ISO 32000-2 §9.9 has an embedded TrueType program drawn as the +//! font program it is. +//! +//! The font is built here, in memory: two glyphs (`.notdef`, empty, and a filled square) and a +//! (1, 0) `cmap` subtable mapping `A` to the square. No font file is involved. + +use super::render_first_page; + +fn be16(out: &mut Vec, n: u16) { + out.extend_from_slice(&n.to_be_bytes()); +} + +fn be32(out: &mut Vec, n: u32) { + out.extend_from_slice(&n.to_be_bytes()); +} + +/// The font, its `loca` offsets long when `long` and followed by `padding` zero bytes, its +/// `head.indexToLocFormat` set to `format`. +fn square_font(long: bool, padding: usize, format: u16) -> Vec { + // Glyph 1: one contour, the square 0..1000 on both axes (glyph 0 is empty). + let mut glyf = Vec::new(); + for n in [1u16, 0, 0, 1000, 1000] { + be16(&mut glyf, n); + } + be16(&mut glyf, 3); // the contour's last point + be16(&mut glyf, 0); // no instructions + glyf.extend([1u8; 4]); // four on-curve points, two-byte coordinates + for dx in [0i16, 1000, 0, -1000] { + glyf.extend_from_slice(&dx.to_be_bytes()); + } + for dy in [0i16, 0, 1000, 0] { + glyf.extend_from_slice(&dy.to_be_bytes()); + } + let mut loca = Vec::new(); + for offset in [0u32, 0, glyf.len() as u32] { + if long { be32(&mut loca, offset) } else { be16(&mut loca, (offset / 2) as u16) } + } + loca.resize(loca.len() + padding, 0); + + let mut head = vec![0; 54]; + head[0..4].copy_from_slice(&[0, 1, 0, 0]); + head[12..16].copy_from_slice(&0x5f0f_3cf5u32.to_be_bytes()); + head[18..20].copy_from_slice(&1000u16.to_be_bytes()); + head[40..42].copy_from_slice(&1000i16.to_be_bytes()); + head[42..44].copy_from_slice(&1000i16.to_be_bytes()); + head[50..52].copy_from_slice(&format.to_be_bytes()); + let mut hhea = vec![0; 36]; + hhea[0..4].copy_from_slice(&[0, 1, 0, 0]); + hhea[4..6].copy_from_slice(&1000i16.to_be_bytes()); + hhea[34..36].copy_from_slice(&2u16.to_be_bytes()); + let mut hmtx = Vec::new(); + for _ in 0..2 { + be16(&mut hmtx, 1000); + be16(&mut hmtx, 0); + } + let mut maxp = vec![0, 1, 0, 0]; + be16(&mut maxp, 2); + maxp.resize(32, 0); + + // (1, 0), format 0: `A` → glyph 1 (the square). + let mut mac = Vec::new(); + be16(&mut mac, 0); + be16(&mut mac, 262); + be16(&mut mac, 0); + let mut ids = [0u8; 256]; + ids[usize::from(b'A')] = 1; + mac.extend(ids); + let mut cmap = Vec::new(); + be16(&mut cmap, 0); + be16(&mut cmap, 1); + be16(&mut cmap, 1); + be16(&mut cmap, 0); + be32(&mut cmap, 12); + cmap.extend(mac); + + let tables = [(*b"cmap", cmap), (*b"glyf", glyf), (*b"head", head), (*b"hhea", hhea), (*b"hmtx", hmtx), (*b"loca", loca), (*b"maxp", maxp)]; + let mut out = vec![0, 1, 0, 0]; + be16(&mut out, tables.len() as u16); + for n in [64u16, 2, 48] { + be16(&mut out, n); + } + let mut payload = Vec::new(); + let mut offset = 12 + 16 * tables.len(); + for (tag, bytes) in tables { + out.extend(tag); + let mut padded = bytes.clone(); + padded.resize(bytes.len().div_ceil(4) * 4, 0); + let sum = padded.as_chunks::<4>().0.iter().fold(0u32, |s, b| s.wrapping_add(u32::from_be_bytes(*b))); + be32(&mut out, sum); + be32(&mut out, offset as u32); + be32(&mut out, bytes.len() as u32); + offset += padded.len(); + payload.extend(padded); + } + out.extend(payload); + out +} + +/// A 40 × 40 pt page that shows `A` at 30 pt from (5, 5) in the font. +fn page_with_font(font: &[u8]) -> Vec { + let content = b"BT /F1 30 Tf 5 5 Td (A) Tj ET".to_vec(); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(), content.clone(), b"\nendstream".to_vec()].concat(), + b"<< /Type /Font /Subtype /TrueType /BaseFont /AAAAAA+Sq /FirstChar 65 /LastChar 65 /Widths [1000] /FontDescriptor 6 0 R >>".to_vec(), + b"<< /Type /FontDescriptor /FontName /AAAAAA+Sq /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 /CapHeight 1000 /StemV 80 /FontFile2 7 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", font.len()).into_bytes(), font.to_vec(), b"\nendstream".to_vec()].concat(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend(format!("{} 0 obj\n", i + 1).into_bytes()); + pdf.extend(object); + pdf.extend(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes()); + for offset in offsets { + pdf.extend(format!("{offset:010} 00000 n \n").into_bytes()); + } + pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); + pdf +} + +#[test] +fn a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs() { + // The square covers (5, 5)–(35, 35) in page space: row 20 from the top is inside it. + let square_drawn = |long: bool, padding: usize, format: u16| { + let page = render_first_page(page_with_font(&square_font(long, padding, format))); + let at = |x: u32, y: u32| page.rgba.get(((y * page.width + x) * 4) as usize).copied().unwrap_or(255); + assert_eq!(at(2, 2), 255, "nothing is drawn outside the square"); + at(20, 20) < 128 + }; + assert!(square_drawn(true, 0, 1), "long offsets, marked long"); + assert!(square_drawn(false, 0, 0), "short offsets, marked short"); + assert!(square_drawn(true, 0, 0x0100), "long offsets marked 256: the table's length says long"); + assert!(square_drawn(false, 0, 0x0100), "short offsets marked 256: the table's length says short"); + // Three short offsets are 6 bytes and three long ones 12: 8 bytes is short ones and padding. + assert!(square_drawn(false, 2, 0x0100), "short offsets and padding marked 256: still short"); +} diff --git a/crates/render/src/raster/tests/type3_proc_cache.rs b/crates/render/src/raster/tests/type3_proc_cache.rs new file mode 100644 index 0000000000..350877e6f8 --- /dev/null +++ b/crates/render/src/raster/tests/type3_proc_cache.rs @@ -0,0 +1,152 @@ +//! A Type 3 glyph shown many times decodes its glyph procedure once (vendored hayro-interpret +//! patch 87). hayro inflated the procedure again for every copy of the glyph, so a font used as +//! a fill pattern, one glyph drawn hundreds of thousands of times, took seconds a page. + +use super::image_cache_pdf; +use hayro::hayro_interpret::font::Glyph; +use hayro::hayro_interpret::hayro_syntax::Pdf; +use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt, + interpret_page, +}; +use kurbo::{Affine, BezPath, Rect, Shape}; +use std::io::Write; +use std::sync::Arc; + +/// Records the width in device pixels of each path drawn, and draws each Type 3 glyph through its +/// procedure, as the renderer does. +#[derive(Default)] +struct Paths(Vec); + +impl<'a> Device<'a> for Paths { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'a>, _: &PathDrawMode) { + self.0.push((transform * path.clone()).bounding_box().width()); + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &Paint<'a>, _: &GlyphDrawMode) { + if let Glyph::Type3(glyph) = glyph { + glyph.interpret(self, transform, glyph_transform, paint); + } + } + fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} +} + +fn flate(data: &[u8]) -> Vec { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() +} + +/// A Type 3 font on an 8 × 8 grid whose glyphs, named `names`, take the codes from 33 on and +/// have their procedures in the objects from `first_object` on. +fn font(names: &[String], first_object: usize) -> String { + let procs: String = names.iter().enumerate().map(|(i, n)| format!("/{n} {} 0 R ", first_object + i)).collect(); + let names_list: String = names.iter().map(|n| format!("/{n} ")).collect(); + format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 8 8] /FontMatrix [0.125 0 0 0.125 0 0] /CharProcs << {procs}>> \ + /Encoding << /Type /Encoding /Differences [33 {names_list}] >> /FirstChar 33 /LastChar {} /Widths [{}] >>", + 32 + names.len(), + "8 ".repeat(names.len()) + ) +} + +/// Renders the page of `bytes` through a new cache; the widths of the paths drawn, and how many +/// glyph procedures were decoded. +fn render(bytes: Vec) -> (Vec, usize) { + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let initial = page.initial_transform(true).to_kurbo(); + let cache = InterpreterCache::new(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Paths::default(); + interpret_page(page, &mut ctx, &mut device); + (device.0, cache.type3_proc_decodes()) +} + +/// A Type 3 font whose one glyph, `!`, is a Flate-compressed procedure filling its 8 × 8 box, +/// shown 200 times on one page and rendered twice through one cache: every copy draws, and the +/// procedure is decoded once, on the first copy. +#[test] +fn a_type3_glyph_shown_again_decodes_its_procedure_once() { + let proc_bytes = flate(b"8 0 0 0 8 8 d1 0 0 8 8 re f"); + let proc_dict = format!("<< /Filter /FlateDecode /Length {} >>", proc_bytes.len()); + let content = format!("BT /F1 0.5 Tf 1 0 0 1 2 30 Tm ({}) Tj ET", "!".repeat(200)); + let resources = format!("<< /Font << /F1 {} >> >>", font(&["dot".into()], 5)); + let parsed = Pdf::new(Arc::new(image_cache_pdf(&content, &resources, &[(&proc_dict, &proc_bytes)]))).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + for render in ["first", "second"] { + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Paths::default(); + interpret_page(page, &mut ctx, &mut device); + assert_eq!(device.0.len(), 200, "the {render} render draws every copy of the glyph"); + assert_eq!(cache.type3_proc_decodes(), 1, "after the {render} render: the procedure is decoded once"); + } +} + +/// A procedure that decodes to more than 64 KiB isn't kept: shown three times, it is decoded three +/// times, and draws each time. +#[test] +fn a_procedure_over_64_kib_is_decoded_for_each_copy() { + let mut program = b"8 0 0 0 8 8 d1 0 0 8 8 re f".to_vec(); + program.resize((64 << 10) + 1, b' '); + let proc_bytes = flate(&program); + let proc_dict = format!("<< /Filter /FlateDecode /Length {} >>", proc_bytes.len()); + let resources = format!("<< /Font << /F1 {} >> >>", font(&["big".into()], 5)); + let bytes = image_cache_pdf("BT /F1 0.5 Tf 1 0 0 1 2 30 Tm (!!!) Tj ET", &resources, &[(&proc_dict, &proc_bytes)]); + let (widths, decodes) = render(bytes); + assert_eq!(widths.len(), 3); + assert_eq!(decodes, 3, "a procedure over the size kept is decoded for each copy"); +} + +/// The fonts of one cache keep at most 32 MiB of procedures together: 600 glyphs of 60,000 bytes +/// each, over three fonts (36 MB), each shown twice, all draw; the procedures kept are decoded +/// once and the rest twice, so there are more decodes than glyphs and fewer than copies. +#[test] +fn the_procedures_one_cache_keeps_stop_at_32_mib() { + let mut program = b"8 0 0 0 8 8 d1 0 0 8 8 re f".to_vec(); + program.resize(60_000, b' '); + let proc_bytes = flate(&program); + let proc_dict = format!("<< /Filter /FlateDecode /Length {} >>", proc_bytes.len()); + let (fonts, per_font) = (3, 200); + let mut resources = String::from("<< /Font << "); + let mut content = String::new(); + for f in 0..fonts { + let names: Vec = (0..per_font).map(|g| format!("g{f}x{g}")).collect(); + resources.push_str(&format!("/F{f} {} ", font(&names, 5 + f * per_font))); + let codes: String = (0..per_font).map(|g| format!("{:02x}", 33 + g)).collect(); + content.push_str(&format!("BT /F{f} 0.05 Tf 1 0 0 1 2 {} Tm <{codes}{codes}> Tj ET ", 10 + 20 * f)); + } + resources.push_str(">> >>"); + let objects: Vec<(&str, &[u8])> = (0..fonts * per_font).map(|_| (proc_dict.as_str(), proc_bytes.as_slice())).collect(); + let bytes = image_cache_pdf(&content, &resources, &objects); + let (widths, decodes) = render(bytes); + assert_eq!(widths.len(), 2 * fonts * per_font, "every copy draws"); + assert!(decodes > fonts * per_font && decodes < 2 * fonts * per_font, "{decodes} decodes: some procedures kept, not all"); +} + +/// Two fonts each with a glyph named `dot`, one filling 8 × 8 and the other 4 × 4, draw their own +/// procedures: what one font keeps isn't looked up by another with the same glyph name. +#[test] +fn fonts_with_the_same_glyph_name_keep_their_own_procedures() { + let (big, small) = (b"8 0 0 0 8 8 d1 0 0 8 8 re f".to_vec(), b"8 0 0 0 8 8 d1 0 0 4 4 re f".to_vec()); + let dict = |data: &[u8]| format!("<< /Length {} >>", data.len()); + let (big_dict, small_dict) = (dict(&big), dict(&small)); + let resources = format!("<< /Font << /A {} /B {} >> >>", font(&["dot".into()], 5), font(&["dot".into()], 6)); + let bytes = image_cache_pdf( + "BT /A 8 Tf 1 0 0 1 2 30 Tm (!!) Tj /B 8 Tf (!!) Tj ET", + &resources, + &[(big_dict.as_str(), big.as_slice()), (small_dict.as_str(), small.as_slice())], + ); + let (widths, _) = render(bytes); + assert_eq!(widths.len(), 4); + let (a, b) = (widths[0], widths[3]); + assert!((a - 2.0 * b).abs() < 0.01, "font A's glyph is twice as wide as font B's: {widths:?}"); + assert_eq!((widths[1], widths[2]), (a, b), "each font draws its own glyph again: {widths:?}"); +} diff --git a/crates/render/src/raster/tests/type3_proc_widths.rs b/crates/render/src/raster/tests/type3_proc_widths.rs new file mode 100644 index 0000000000..bf11775af9 --- /dev/null +++ b/crates/render/src/raster/tests/type3_proc_widths.rs @@ -0,0 +1,130 @@ +//! A Type 3 font without `/Widths` advances each glyph by the width its glyph procedure sets +//! with `d0` or `d1` (vendored hayro-interpret patch 90). ISO 32000-2 Table 110 requires +//! `/Widths` in a Type 3 font, and §9.6.4 has the procedure state the same width: "wx … shall +//! be consistent with the corresponding width in the font's Widths array". A font whose +//! `/Widths` key was misspelt (PDFBOX-2943) gave every glyph no advance, so its text was drawn +//! as one blot and read as characters stacked on one spot. PDFium takes the procedure's width +//! here. A font that has `/Widths` is unchanged: Table 110 gives a code outside `/FirstChar` to +//! `/LastChar` width 0, as Poppler and MuPDF draw it. +//! +//! The glyphs are squares 400 units wide (`a`, set by `d1`, advance 500) and 600 units wide +//! (`b`, set by `d0`, advance 700), shown at 100 pt from x = 20. + +use super::render_first_page; +use crate::RenderWarning; +use crate::text::extract_page; +use hayro::hayro_interpret::InterpreterSettings; +use hayro::hayro_interpret::hayro_syntax::Pdf; +use std::sync::Arc; + +/// A page showing `aabb` in a Type 3 font whose dictionary ends with `widths`. +fn type3_page(widths: &str) -> Vec { + type3_page_with(widths, "aabb", "700 0 d0 0 0 600 600 re f") +} + +/// A page showing `text` in a Type 3 font whose dictionary ends with `widths` and whose `b` +/// glyph is `glyph_b`. +fn type3_page_with(widths: &str, text: &str, glyph_b: &str) -> Vec { + let content = format!("BT /F1 100 Tf 20 100 Td ({text}) Tj ET"); + let glyph_a = "500 0 0 0 400 400 d1 0 0 400 400 re f"; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] \ + /CharProcs << /a 6 0 R /b 7 0 R >> /Encoding << /Type /Encoding /Differences [97 /a /b] >> {widths} >>" + ), + format!("<< /Length {} >>\nstream\n{glyph_a}\nendstream", glyph_a.len()), + format!("<< /Length {} >>\nstream\n{glyph_b}\nendstream", glyph_b.len()), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend(format!("{} 0 obj\n{object}\nendobj\n", i + 1).into_bytes()); + } + let xref = pdf.len(); + pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes()); + for offset in offsets { + pdf.extend(format!("{offset:010} 00000 n \n").into_bytes()); + } + pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); + pdf +} + +/// The columns (start, end) of the rendered page that hold ink, as runs. +fn ink_runs(pdf: Vec) -> Vec<(u32, u32)> { + let page = render_first_page(pdf); + let inked = |x: u32| (0..page.height).any(|y| page.rgba.get(((y * page.width + x) * 4) as usize).is_some_and(|v| *v < 128)); + let mut runs = Vec::new(); + let mut start = None; + for x in 0..=page.width { + match (x < page.width && inked(x), start) { + (true, None) => start = Some(x), + (false, Some(s)) => { + runs.push((s, x)); + start = None; + } + _ => {} + } + } + runs +} + +/// The left edge of each extracted glyph, rounded to a point. +fn glyph_lefts(pdf: Vec) -> Vec { + let pdf = Pdf::new(Arc::new(pdf)).expect("parses"); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).expect("text"); + text.glyphs.iter().map(|g| g.rect[0].round() as i32).collect() +} + +#[test] +fn a_type3_font_without_widths_advances_by_its_glyph_procedures() { + // Each glyph in its own place: a at 20 and 70 (d1 width 500), b at 120 and 190 (d0 700). + for widths in ["", "/FirstChar 97 /LastChar 98 /WXdths [300 300]"] { + assert_eq!(ink_runs(type3_page(widths)), [(20, 60), (70, 110), (120, 180), (190, 250)], "{widths:?}"); + assert_eq!(glyph_lefts(type3_page(widths)), [20, 70, 120, 190], "{widths:?}"); + } +} + +#[test] +fn a_code_outside_first_char_to_last_char_still_has_width_zero() { + // a takes /Widths (300); b, past /LastChar, has width 0 (Table 110), whatever d0 says. + let widths = "/FirstChar 97 /LastChar 97 /Widths [300]"; + assert_eq!(ink_runs(type3_page(widths)), [(20, 140)]); + assert_eq!(glyph_lefts(type3_page(widths)), [20, 50, 80]); +} + +#[test] +fn widths_the_font_gives_are_kept() { + // /Widths says 300 for both, whatever the procedures say: the glyphs overlap, as written. + let widths = "/FirstChar 97 /LastChar 98 /Widths [300 300]"; + assert_eq!(ink_runs(type3_page(widths)), [(20, 170)]); + assert_eq!(glyph_lefts(type3_page(widths)), [20, 50, 80, 110]); + // An explicit 0 is an entry too: every glyph stays at x = 20 (text extraction keeps one + // of each glyph drawn again on the same spot). + let zero = "/FirstChar 97 /LastChar 98 /Widths [0 0]"; + assert_eq!(glyph_lefts(type3_page(zero)), [20, 20]); +} + +#[test] +fn a_code_without_a_usable_procedure_width_still_advances_nothing() { + // b's d0 has a name for its width and c has no procedure: neither advances, and neither + // panics. + assert_eq!(glyph_lefts(type3_page_with("", "bcb", "/x 0 d0 0 0 600 600 re f")), [20, 20]); +} + +#[test] +fn a_type3_font_without_widths_is_reported() { + // The page draws, and says once that a font's /Widths was missing; a font with /Widths + // (even one that leaves `b` out) says nothing. + for widths in ["", "/FirstChar 97 /LastChar 98 /WXdths [300 300]"] { + let page = render_first_page(type3_page(widths)); + assert_eq!(page.warnings, [RenderWarning::Type3WidthsMissing], "{widths:?}"); + } + for widths in ["/FirstChar 97 /LastChar 98 /Widths [300 300]", "/FirstChar 97 /LastChar 97 /Widths [300]"] { + assert!(render_first_page(type3_page(widths)).warnings.is_empty(), "{widths:?}"); + } +} diff --git a/crates/render/src/text/device.rs b/crates/render/src/text/device.rs index 6c14325400..a04e380788 100644 --- a/crates/render/src/text/device.rs +++ b/crates/render/src/text/device.rs @@ -458,9 +458,17 @@ impl<'a> Device<'a> for TextDevice { eprintln!("{kind} {text:?} transform={:?} glyph_transform={:?}", transform.as_coeffs(), glyph_transform.as_coeffs()); } let advance = match glyph { - Glyph::Outline(g) => { - g.advance_width().filter(|a| *a > 0.0).map(f64::from).unwrap_or_else(|| g.outline().bounding_box().width().max(500.0)) - } + // A vertical font's advance runs down the column; across it, the glyph is as wide as + // its horizontal width w0, centred on the column by default (ISO 32000-2 §9.7.4.3). Taking + // the outline's width instead gave a column of Latin letters boxes of uneven widths, + // off its centre, and a narrow letter cut the line's gap allowance below the spacing + // of widely tracked text, so the column fell apart into lines. + Glyph::Outline(g) => g + .vertical_glyph_width() + .or_else(|| g.advance_width()) + .filter(|a| a.is_finite() && *a > 0.0) + .map(f64::from) + .unwrap_or_else(|| g.outline().bounding_box().width().max(500.0)), Glyph::Type3(g) => g.advance_width().filter(|a| a.is_finite() && *a > 0.0).map(f64::from).unwrap_or(600.0), }; // The baseline direction in view space (y down). diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 9e7c7a7093..3b06ec7f14 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -664,10 +664,12 @@ fn the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space() { /// #3169, #3170: glyph names outside the Adobe Glyph List still read as text. Ligature /// (`f_i`) and suffixed (`hyphen.case`) names go by the glyph list specification's full -/// mapping. A name that only numbers the glyph (`c251` small caps, `C39`, `C174`, `C39.sc`) -/// reads as nothing, not as a guess from the code (review on #3239: with `/C1` at 233, -/// StandardEncoding's 233 would read `Ø` for a glyph drawn as `é`), and so do a ZapfDingbats -/// name (`a12`) and a name with no meaning left (`foo`). +/// mapping. A name that only numbers the glyph reads as nothing, not as a guess from the code +/// (review on #3239: with `/C1` at 233, StandardEncoding's 233 would read `Ø` for a glyph drawn +/// as `é`). `C39` at 39 and `C174` at 174 name their own codes, but this font's other numbered +/// names (`c251` at 97, `C39.sc` at 61, `C1` at 233, `a12` at 88) don't, so they read as +/// nothing too (see `numbered_glyph_names_read_as_their_own_code`). A name with no meaning +/// left (`foo`) reads as nothing. #[test] fn glyph_names_outside_the_glyph_list_still_read_as_text() { use lopdf::{Object, dictionary}; @@ -696,11 +698,145 @@ fn glyph_names_outside_the_glyph_list_still_read_as_text() { assert_eq!(lines, ["fi", "fl", "-", "fi"], "{text:?}"); } +/// A glyph name that only numbers its glyph, with no `/ToUnicode` (ISO 32000-2 §9.10.2 has no +/// route for it), reads as its code's Latin-1 character when the number is that very code, in +/// decimal (`C70` at 70, `C0071` at 71, `MT72` at 72, `C39` at 39, `C174` at 174) or +/// hexadecimal (`G49` at 0x49, `GE9` at 0xE9), as pdftotext, PDFium and MuPDF read +/// it: Windows printer drivers and typesetters name subset glyphs so. Only in a font where every +/// numbered name names its own code: one more name numbering another code (`/C1` at 233, review +/// on #3239) turns the whole font back to nothing, and so does a font whose names number +/// something else, where a name can match its code by coincidence (`/MT40` at 0x40). A subset +/// that counts its glyphs in order of first use from code 1 (`/Differences [1 /C1 /C2 … /C80]`) +/// names its own codes too, but `/C65` there is the 65th glyph used, not `A`: a numbered name at +/// a control code (`C1` at 1, `C9` at 9) gives the counter away and the font reads nothing. +/// Code 32 is the first code a numbered name may name: `C32` at 32 reads as a space. +#[test] +fn numbered_glyph_names_read_as_their_own_code() { + use lopdf::{Object, dictionary}; + let name = |n: &str| Object::Name(n.as_bytes().to_vec()); + let encoding = |differences: Vec| Object::Dictionary(dictionary! { "Type" => "Encoding", "Differences" => differences }); + // One glyph per line, as in `glyph_names_outside_the_glyph_list_still_read_as_text`. + let one_per_line = |codes: &[u8]| { + let mut content = b"BT /F1 20 Tf 20 290 Td".to_vec(); + for code in codes { + content.extend_from_slice(format!(" <{code:02x}> Tj 0 -25 Td").as_bytes()); + } + content.extend_from_slice(b" ET"); + content + }; + let own = encoding(vec![ + Object::Integer(70), + name("C70"), + name("C0071"), + name("MT72"), + name("G49"), + Object::Integer(233), + name("GE9"), + Object::Integer(39), + name("C39"), + Object::Integer(174), + name("C174"), + ]); + let one_other = encoding(vec![Object::Integer(70), name("C70"), Object::Integer(233), name("C1")]); + let numbered_otherwise = encoding(vec![Object::Integer(1), name("C70"), name("C105"), Object::Integer(64), name("MT40")]); + let counter = encoding(std::iter::once(Object::Integer(1)).chain((1..=80).map(|n| name(&format!("C{n}")))).collect()); + let own_and_a_control = encoding(vec![Object::Integer(9), name("C9"), Object::Integer(70), name("C70"), name("C0071")]); + // Code 32 is the first code a numbered name may name: `/C32` at 32 is a space, not a counter. + let own_from_32 = encoding(vec![Object::Integer(32), name("C32"), Object::Integer(70), name("C70"), name("C0071")]); + let pages = [ + one_per_line(b"FGHI\xe9'\xae"), + one_per_line(b"F\xe9"), + one_per_line(b"\x01\x02@"), + one_per_line(b"\x01\x20\x41\x42\x50"), + one_per_line(b"\x09FG"), + b"BT /F1 20 Tf 20 290 Td <462047> Tj ET".to_vec(), + ]; + let pdf = encoded_pages(&[ + (own, &pages[0][..]), + (one_other, &pages[1][..]), + (numbered_otherwise, &pages[2][..]), + (counter, &pages[3][..]), + (own_and_a_control, &pages[4][..]), + (own_from_32, &pages[5][..]), + ]); + let lines = |page| { + let text = extract_page(&pdf, page, &InterpreterSettings::default()).unwrap().plain_text(); + text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect::>() + }; + assert_eq!(lines(0), ["F", "G", "H", "I", "\u{e9}", "'", "\u{ae}"]); + assert!(lines(1).is_empty(), "/C1 at 233 beside /C70 at 70: {:?}", lines(1)); + assert!(lines(2).is_empty(), "/MT40 at 0x40 among names numbering other codes: {:?}", lines(2)); + assert!(lines(3).is_empty(), "a first-use counter /C1 … /C80 from code 1: {:?}", lines(3)); + assert!(lines(4).is_empty(), "/C9 at 9 beside /C70 at 70: {:?}", lines(4)); + let page5 = extract_page(&pdf, 5, &InterpreterSettings::default()).unwrap().plain_text(); + assert_eq!(page5.trim_matches('\n'), "F G", "/C32 at 32 beside /C70 at 70 reads as a space"); +} + +/// `numbered_glyph_names_read_as_their_own_code` in a Type 3 font, which already reads codes +/// 32-126 by the code: above 126, `C174` at 174, `C176` at 176 and `GE9` at 0xE9 read as the +/// code's Latin-1 character, unless one numbered name in the font numbers another code +/// (`/C1` at 233) or sits at a control code, as a first-use counter's `/C1` at 1 does. +#[test] +fn numbered_type3_glyph_names_read_as_their_own_code() { + use lopdf::{Document, Object, Stream, dictionary}; + let name = |n: &str| Object::Name(n.as_bytes().to_vec()); + // Each font's /Differences as (code, glyph name) pairs, and the codes its page shows. + type Names<'a> = &'a [(i64, &'a str)]; + let fonts: [(Names, &[u8]); 3] = [ + (&[(70, "C70"), (174, "C174"), (176, "C176"), (233, "GE9")], b"F\xae\xb0\xe9"), + (&[(174, "C174"), (176, "C176"), (233, "C1")], b"\xae\xb0\xe9"), + (&[(1, "C1"), (2, "C2"), (174, "C174"), (176, "C176")], b"\xae\xb0"), + ]; + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let mut kids = Vec::new(); + for (names, codes) in fonts { + let glyph = doc.add_object(Stream::new(dictionary! {}, b"600 0 0 0 500 700 d1 0 0 500 700 re f".to_vec())); + let mut differences = Vec::new(); + let mut procs = lopdf::Dictionary::new(); + for &(code, glyph_name) in names { + differences.extend([Object::Integer(code), name(glyph_name)]); + procs.set(glyph_name, glyph); + } + let font = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => "Type3", + "FontBBox" => vec![0.into(), 0.into(), 1000.into(), 1000.into()], + "FontMatrix" => vec![0.001.into(), 0.into(), 0.into(), 0.001.into(), 0.into(), 0.into()], + "CharProcs" => procs, + "Encoding" => dictionary! { "Type" => "Encoding", "Differences" => differences }, + "FirstChar" => 0, "LastChar" => 255, "Widths" => vec![Object::Integer(600); 256], + }); + // One glyph per line, as in `glyph_names_outside_the_glyph_list_still_read_as_text`. + let mut content = b"BT /T3 20 Tf 20 290 Td".to_vec(); + for code in codes { + content.extend_from_slice(format!(" <{code:02x}> Tj 0 -25 Td").as_bytes()); + } + content.extend_from_slice(b" ET"); + let content = doc.add_object(Stream::new(dictionary! {}, content)); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages_id, "MediaBox" => vec![0.into(), 0.into(), 300.into(), 300.into()], "Resources" => dictionary! {"Font" => dictionary! {"T3" => font}}, "Contents" => content }); + kids.push(Object::Reference(page)); + } + let count = kids.len() as i64; + doc.objects.insert(pages_id, dictionary! {"Type" => "Pages", "Kids" => kids, "Count" => count}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages_id}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let lines = |page| { + let text = extract_page(&pdf, page, &InterpreterSettings::default()).unwrap().plain_text(); + text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect::>() + }; + assert_eq!(lines(0), ["F", "\u{ae}", "\u{b0}", "\u{e9}"]); + assert!(lines(1).is_empty(), "/C1 at 233 beside /C174 at 174: {:?}", lines(1)); + assert!(lines(2).is_empty(), "a counter's /C1 at 1 beside /C174 at 174: {:?}", lines(2)); +} + /// Review on #3239: the glyph list specification's `uni` and `u` forms take uppercase hex /// digits only, and map a name with lowercase ones to nothing ("uni20ac" in its examples). /// Names upstream's own lookup can't read (several groups, a suffix) show it: lowercase reads -/// as nothing, uppercase as its characters. A bare `uni20ac` or `u1040c` is still read by -/// upstream's lenient `unicode_from_name`, outside this mapping. +/// as nothing, uppercase as its characters. A bare `uni20ac` or `u1040c` reads as nothing too +/// (`agl_unicode_from_name_rule_reads_text`). #[test] fn glyph_names_with_lowercase_hex_read_as_nothing() { use lopdf::{Object, dictionary}; @@ -720,6 +856,34 @@ fn glyph_names_with_lowercase_hex_read_as_nothing() { assert_eq!(lines, ["\u{20ac}\u{308}", "\u{1040c}", "f", "f\u{20ac}"], "{text:?}"); } +/// The glyph list specification's `uni` form is `uni` and groups of exactly four uppercase hex +/// digits, its `u` form `u` and four to six, outside the surrogate range: `uni65`, `u65`, +/// `uni20ac`, `u1f600`, `uni+065` and `uniD800` name no character, and their glyphs read as +/// nothing rather than as `e`, `€` or an emoji. The well-formed names read as before. +#[test] +fn agl_unicode_from_name_rule_reads_text() { + use lopdf::{Object, dictionary}; + let name = |n: &str| Object::Name(n.as_bytes().to_vec()); + let names = ["uni65", "u65", "uni20ac", "u1f600", "uni+065", "uniD800", "u10FFFF0", "uni0065", "u0066", "u1F600", "uni20AC"]; + let differences = Object::Dictionary(dictionary! { + "Type" => "Encoding", + "Differences" => std::iter::once(Object::Integer(65)).chain(names.iter().map(|n| name(n))).collect::>(), + }); + // One glyph per line, so each name's text stays its own (and a name read as nothing + // leaves no line). + let mut content = b"BT /F1 20 Tf 20 290 Td".to_vec(); + for code in (b'A'..).take(names.len()) { + content.extend_from_slice(b" ("); + content.push(code); + content.extend_from_slice(b") Tj 0 -25 Td"); + } + content.extend_from_slice(b" ET"); + let pdf = encoded_pages(&[(differences, &content[..])]); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text(); + let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty()).collect(); + assert_eq!(lines, ["e", "f", "\u{1f600}", "\u{20ac}"], "{text:?}"); +} + #[test] fn narrow_ligatures_keep_every_letter() { // Split evenly, this "ffi" put its two "f"s so close that the second was dropped as a @@ -771,6 +935,113 @@ fn vertical_ligatures_stay_in_their_column() { assert_eq!(text.line_rects(0..3).len(), 1, "one column"); } +/// A column of Latin capitals in a vertical font, tracked out with negative character spacing +/// (pdf.js issue 20930: `-40 Tc` puts 40 pt between 24 pt letters). Across the column each glyph +/// is as wide as its `/W` width w0 and centred on the column (ISO 32000-2 §9.7.4.3), so the +/// letters stay one line and Find matches the word. Boxes as wide as each outline made a narrow +/// letter's line allowance smaller than the tracking, and the column broke into lines. +#[test] +fn tracked_vertical_latin_stays_one_line() { + tracked_vertical_latin(-40.0, 100.0); +} + +#[test] +fn horizontally_scaled_vertical_latin_box_is_scaled_across_the_column() { + // Horizontal scaling Th = Tz / 100 scales the glyph's width w0 across the column (ISO 32000-2 + // §9.4.4, the text rendering matrix), and not the advance down it. The tracking is 20 pt here: + // the line's gap allowance is three times the narrower box across it, 3 × 8 pt at 50 Tz. + tracked_vertical_latin(-20.0, 50.0); +} + +/// `ABCDE` down one Identity-V column at 24 pt with `tc Tc` and `tz Tz`. +fn tracked_vertical_latin(tc: f32, tz: f32) { + let text = vertical_latin( + &[(36, 667), (37, 667), (38, 722), (39, 722), (40, 667)], + None, + &format!("BT /F1 24 Tf {tc} Tc {tz} Tz 1 0 0 1 100 340 Tm <00240025002600270028> Tj ET"), + ); + assert_eq!(text.plain_text(), "ABCDE"); + assert_eq!(text.find("ABCDE"), vec![0..5]); + // Each box spans w0 (here 24 pt × 0.667 or 0.722, times Th) centred on the column at x = 100. + assert_eq!(text.glyphs.len(), 5); + for (g, w0) in text.glyphs.iter().zip([667.0, 667.0, 722.0, 722.0, 667.0]) { + let [x0, _, x1, _] = g.rect; + assert!((x1 - x0 - 24.0 * w0 / 1000.0 * tz / 100.0).abs() < 0.01, "{:?} is w0 wide: {:?}", g.text, g.rect); + assert!(((x0 + x1) / 2.0 - 100.0).abs() < 0.01, "{:?} is centred on the column: {:?}", g.text, g.rect); + } +} + +/// The text of a page showing `content` in a vertical CIDFontType2 `/F1` with `/DW 1000`, the +/// `widths` (CID, w0) in `/W`, and CIDs 36 to 41 reading `A` to `F`. `cmap` is the body of an +/// embedded vertical CMap mapping codes to CIDs (`begincidchar` … `endcidchar`); `None` uses +/// Identity-V. +fn vertical_latin(widths: &[(i64, i64)], cmap: Option<&str>, content: &str) -> crate::text::PageText { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + let to_unicode = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /TestUnicode def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 6 beginbfchar <0024> <0041> <0025> <0042> <0026> <0043> <0027> <0044> <0028> <0045> <0029> <0046> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"; + let unicode = doc.add_object(Stream::new(dictionary! {}, to_unicode.to_vec())); + let widths: Vec = widths.iter().flat_map(|&(cid, w)| [Object::Integer(cid), Object::Array(vec![Object::Integer(w)])]).collect(); + let cid = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => "CIDFontType2", "BaseFont" => "TestVerticalLatin", + "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 }, + "DW" => 1000, "W" => widths, "DW2" => vec![Object::Integer(880), Object::Integer(-1000)], "CIDToGIDMap" => "Identity" + }); + let encoding = match cmap { + Some(body) => { + let stream = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> def /CMapName /TestCodes-V def /CMapType 1 def /WMode 1 def 1 begincodespacerange <0000> endcodespacerange {body} endcmap CMapName currentdict /CMap defineresource pop end end" + ); + Object::Reference(doc.add_object(Stream::new( + dictionary! { "Type" => "CMap", "CMapName" => "TestCodes-V", "WMode" => 1, "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 } }, + stream.into_bytes(), + ))) + } + None => Object::Name(b"Identity-V".to_vec()), + }; + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "TestVerticalLatin", "Encoding" => encoding, "DescendantFonts" => vec![Object::Reference(cid)], "ToUnicode" => unicode }); + let content = doc.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(500)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap() +} + +/// A CID that `/W` doesn't list is `/DW` wide across the column (ISO 32000-2 §9.7.4.3), and the +/// widths are looked up by CID, not by character code: an embedded vertical CMap maps the codes +/// `<0024>`… (which `/W` lists 500 wide as CIDs) to CIDs 50…. The tracked column stays one line +/// that Find matches. +#[test] +fn vertical_latin_widths_come_from_dw_and_are_looked_up_by_cid() { + let cmap = "5 begincidchar <0024> 50 <0025> 51 <0026> 52 <0027> 53 <0028> 54 endcidchar"; + // CID 52 (`C`) has no /W entry: it is /DW = 1000 wide. + let widths = [(36, 500), (37, 500), (38, 500), (39, 500), (40, 500), (50, 667), (51, 667), (53, 722), (54, 667)]; + let text = vertical_latin(&widths, Some(cmap), "BT /F1 24 Tf -40 Tc 1 0 0 1 100 340 Tm <00240025002600270028> Tj ET"); + assert_eq!(text.plain_text(), "ABCDE"); + assert_eq!(text.find("ABCDE"), vec![0..5]); + let got: Vec = text.glyphs.iter().map(|g| g.rect[2] - g.rect[0]).collect(); + assert_eq!(got.len(), 5); + for (w, w0) in got.iter().zip([667.0, 667.0, 1000.0, 722.0, 667.0]) { + assert!((w - 24.0 * w0 / 1000.0).abs() < 0.01, "{got:?}"); + } +} + +/// Two vertical columns of `/DW`-wide letters set 13.5 pt apart at 20 pt overlap across the +/// column (each box is 20 pt wide), and still read as two lines, in order, with Find matching +/// each column and not a word spanning both. +#[test] +fn close_vertical_columns_of_dw_wide_letters_stay_apart() { + let text = vertical_latin(&[], None, "BT /F1 20 Tf 1 0 0 1 113.5 340 Tm <002400250026> Tj 1 0 0 1 100 340 Tm <002700280029> Tj ET"); + assert_eq!(text.plain_text().lines().map(str::trim).collect::>(), ["ABC", "DEF"]); + assert_eq!(text.find("ABC").len(), 1); + assert_eq!(text.find("DEF").len(), 1); + assert!(text.find("CD").is_empty() && text.find("ABCDEF").is_empty(), "{:?}", text.plain_text()); +} + #[test] fn upright_mapping_round_trips() { let r = [10.0, 20.0, 30.0, 25.0]; @@ -2176,6 +2447,161 @@ fn text_outside_the_crop_box_is_clipped() { } } +/// A one-page document showing `codes` in a Type 0 font with `encoding` over an embedded +/// `descendant` (`CIDFontType0` takes the bundled CFF program, `CIDFontType2` a TrueType one) +/// of the Adobe-Japan1 collection, with `to_unicode` as its `/ToUnicode` CMap when given. +fn japan1_page(encoding: &str, descendant: &str, to_unicode: Option<&[u8]>, codes: &str) -> String { + collection_page(("Adobe", "Japan1"), encoding, descendant, to_unicode, codes) +} + +/// [`japan1_page`] for the character collection `(registry, ordering)`. +fn collection_page(collection: (&str, &str), encoding: &str, descendant: &str, to_unicode: Option<&[u8]>, codes: &str) -> String { + use lopdf::{Document, Object, Stream, dictionary}; + let (registry, ordering) = collection; + let mut doc = Document::with_version("1.7"); + let (key, program): (&str, &[u8]) = if descendant == "CIDFontType0" { + ("FontFile3", include_bytes!("../../../../vendor/hayro-interpret/assets/FoxitFixed.pfb")) + } else { + ("FontFile2", include_bytes!("../../../../assets/fonts/Inter-Regular.ttf")) + }; + let mut file = dictionary! {}; + if descendant == "CIDFontType0" { + file.set("Subtype", "CIDFontType0C"); + } + let program = doc.add_object(Stream::new(file, program.to_vec())); + let descriptor = doc.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => "TestJapan1", "Flags" => 4, key => program }); + let cid = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => descendant, "BaseFont" => "TestJapan1", + "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal(registry), "Ordering" => Object::string_literal(ordering), "Supplement" => 6 }, + "FontDescriptor" => descriptor, "DW" => 1000, "CIDToGIDMap" => "Identity" + }); + let mut font = dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "TestJapan1", "Encoding" => encoding, "DescendantFonts" => vec![Object::Reference(cid)] }; + if let Some(cmap) = to_unicode { + font.set("ToUnicode", doc.add_object(Stream::new(dictionary! {}, cmap.to_vec()))); + } + let font = doc.add_object(font); + let content = doc.add_object(Stream::new(dictionary! {}, format!("BT /F1 20 Tf 1 0 0 1 20 250 Tm <{codes}> Tj ET").into_bytes())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(300), Object::Integer(300)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text() +} + +/// An embedded font of an Adobe character collection reads as text through the collection's +/// UCS2 CMap, code to CID to Unicode (ISO 32000-2 §9.10.2), where its `/ToUnicode` is missing or +/// has nothing for a code: with Identity-H or a predefined CMap such as `/H`, CFF or TrueType. +/// Only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts +/// are embedded without a `/ToUnicode` had no text to select, find or copy. `/ToUnicode` still +/// comes first where it maps a code. +#[test] +fn an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap() { + // Adobe-Japan1 CIDs 34, 1125 and 843: A, 亜 and あ. + assert_eq!(japan1_page("Identity-H", "CIDFontType0", None, "00220465034B"), "A亜あ", "Identity-H, CFF, no /ToUnicode"); + assert_eq!(japan1_page("Identity-H", "CIDFontType2", None, "00220465034B"), "A亜あ", "Identity-H, TrueType, no /ToUnicode"); + // `/H` maps JIS X 0208 codes to CIDs: 3021 亜, 2422 あ, 2341 A. + assert_eq!(japan1_page("H", "CIDFontType2", None, "302124222341"), "亜あA", "predefined /H"); + let empty = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Empty def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(japan1_page("Identity-H", "CIDFontType2", Some(empty), "00220465034B"), "A亜あ", "a /ToUnicode with no mappings"); + let one = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /One def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 1 beginbfchar <0022> <005A> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(japan1_page("Identity-H", "CIDFontType0", Some(one), "00220465034B"), "Z亜あ", "/ToUnicode first, the UCS2 CMap for the rest"); +} + +/// The same route for the other Adobe collections, each through its own UCS2 CMap +/// (Adobe-GB1-UCS2, Adobe-CNS1-UCS2, Adobe-Korea1-UCS2), and for vertical writing (Identity-V) +/// and a predefined CMap over a CFF program. The pairs are read from the bundled CMaps. +#[test] +fn every_adobe_collection_reads_through_its_own_ucs2_cmap() { + for descendant in ["CIDFontType0", "CIDFontType2"] { + // Adobe-GB1 CID 4000 is 姓, Adobe-CNS1 CID 4000 is 團, Adobe-Korea1 CID 2000 is 밧; CID 34 + // is A in each. + for (ordering, codes, text) in [("GB1", "00220FA0", "A姓"), ("CNS1", "00220FA0", "A團"), ("Korea1", "002207D0", "A밧")] { + assert_eq!(collection_page(("Adobe", ordering), "Identity-H", descendant, None, codes), text, "Adobe-{ordering}, {descendant}"); + } + assert_eq!(japan1_page("Identity-V", descendant, None, "00220465034B"), "A亜あ", "Identity-V, {descendant}"); + } + assert_eq!(japan1_page("H", "CIDFontType0", None, "302124222341"), "亜あA", "predefined /H, CFF"); +} + +/// A Type 0 font outside the Adobe collections has no UCS2 CMap: without a `/ToUnicode` its codes +/// give no text through this route (the CIDs mean nothing outside their collection). +#[test] +fn a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap() { + for descendant in ["CIDFontType0", "CIDFontType2"] { + assert_eq!(collection_page(("Example", "Custom"), "Identity-H", descendant, None, "00220465034B"), "", "{descendant}"); + } +} + +/// A Type 0 font whose `/ToUnicode` is written as a CID CMap (`begincidchar`, destinations as +/// numbers, as Word writes it) reads each number as the code's Unicode value (vendored +/// hayro-interpret patch 85), as glyph lookup already did and as pdftotext and PDFium extract it. +/// Read as a bfchar map only, such text extracted nothing (pdf.js `issue9367`). +#[test] +fn a_tounicode_written_as_cid_ranges_gives_text() { + let two = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Words def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 3 begincidchar <0001> 84 <0002> 101 <0004> 116 endcidchar 1 begincidrange <0003> <0003> 120 endcidrange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(cid_range_to_unicode_text(two), "Text", "two-byte entries"); + // Entries written with four-byte codes are found too (review on #6359). + let four = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Words def /CMapType 2 def 1 begincodespacerange <00000000> endcodespacerange 2 begincidchar <00000001> 84 <00000002> 101 endcidchar 1 begincidrange <00000003> <00000004> 120 endcidrange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(cid_range_to_unicode_text(four), "Texy", "four-byte entries"); +} + +/// When a `/ToUnicode` gives one code both a bf entry and a CID entry, the bf entry is the +/// code's text (ISO 32000-2 §9.10.3 defines `/ToUnicode` by `beginbfchar` / `beginbfrange`); the +/// CID number is read only for codes no bf entry covers (vendored hayro-interpret patch 85). +#[test] +fn a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code() { + // Codes 1 and 3 have both kinds of entry: bf says `T` / `x`, CID says `X` / `Y`. Codes 2 and 4 + // have CID entries only. + let two = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Words def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 3 begincidchar <0001> 88 <0002> 101 <0004> 116 endcidchar 1 begincidrange <0003> <0003> 89 endcidrange 1 beginbfchar <0001> <0054> endbfchar 1 beginbfrange <0003> <0003> <0078> endbfrange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(cid_range_to_unicode_text(two), "Text", "two-byte entries"); + // The same with one-byte entries. For two-byte entries the CID lookup, tried at one byte + // first, already falls back to the bf entries at that length; one-byte entries are where + // reading CID entries before bf entries would show, as `XeYt`. + let one = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Words def /CMapType 2 def 1 begincodespacerange <00> endcodespacerange 3 begincidchar <01> 88 <02> 101 <04> 116 endcidchar 1 begincidrange <03> <03> 89 endcidrange 1 beginbfchar <01> <0054> endbfchar 1 beginbfrange <03> <03> <0078> endbfrange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(cid_range_to_unicode_text(one), "Text", "one-byte entries"); +} + +/// The page [`a_tounicode_written_as_cid_ranges_gives_text`] and +/// [`a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code`] read: four codes in a Type 0 font +/// (Inter, Identity-H) whose `/ToUnicode` is `cmap`. +fn cid_range_to_unicode_text(cmap: &[u8]) -> String { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + let program = doc.add_object(Stream::new(dictionary! {}, include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec())); + let descriptor = doc.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => "Inter-Regular", "Flags" => 4, "FontFile2" => program }); + let cid_font = doc.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "CIDFontType2", + "BaseFont" => "Inter-Regular", + "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 }, + "FontDescriptor" => descriptor, + "CIDToGIDMap" => "Identity", + }); + let to_unicode = doc.add_object(Stream::new(dictionary! {}, cmap.to_vec())); + let font = doc.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "Type0", + "BaseFont" => "Inter-Regular", + "Encoding" => "Identity-H", + "DescendantFonts" => vec![Object::Reference(cid_font)], + "ToUnicode" => to_unicode, + }); + let content = doc.add_object(Stream::new(dictionary! {}, b"BT /F1 20 Tf 20 200 Td <0001000200030004> Tj ET".to_vec())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text().trim().to_string() +} + /// A page box that encloses no area is read as a missing one (vendored hayro-syntax patch 38, /// ISO 32000-2 §14.11.2): a `/MediaBox [0 0 0 0]` as the default page, a `/CropBox [0 0 0 0]`, /// or one with nothing in common with the media box, as the media box. The page's visible region diff --git a/crates/render/tests/it/cid_cff_unusable_font_dict.rs b/crates/render/tests/it/cid_cff_unusable_font_dict.rs new file mode 100644 index 0000000000..9b40b5277c --- /dev/null +++ b/crates/render/tests/it/cid_cff_unusable_font_dict.rs @@ -0,0 +1,167 @@ +//! A CID-keyed CFF font (`FontFile3` of subtype `CIDFontType0C`, ISO 32000-2 §9.9 Table 124) +//! selects a font dict for each glyph through its FDSelect, and a glyph's charstring needs the +//! Private DICT of that font dict (Adobe TN #5176). A subsetter can empty the font dicts no kept +//! glyph uses, leaving them without a Private DICT. Those font dicts make only their own glyphs +//! missing; the glyphs of the other font dicts still draw (hayro-interpret patch (93)). +//! +//! The font is built here: `.notdef` and two 500-unit squares, CID 1 in the first font dict and +//! CID 2 in the second, which is empty. + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// A CFF INDEX with one-byte offsets. +fn index(items: &[&[u8]]) -> Vec { + let mut out = u16::try_from(items.len()).unwrap().to_be_bytes().to_vec(); + if items.is_empty() { + return out; + } + out.push(1); + let mut offset = 1u8; + out.push(offset); + for item in items { + offset += u8::try_from(item.len()).unwrap(); + out.push(offset); + } + for item in items { + out.extend_from_slice(item); + } + out +} + +/// A DICT integer operand in the 5-byte form, so every DICT has a fixed size. +fn int(value: usize) -> Vec { + let mut out = vec![29]; + out.extend_from_slice(&u32::try_from(value).unwrap().to_be_bytes()); + out +} + +fn top_dict(charset: usize, fd_select: usize, char_strings: usize, fd_array: usize) -> Vec { + let mut dict = Vec::new(); + // ROS comes first in a CID font's Top DICT: (Adobe) (Identity) 0. + for operand in [391, 392, 0] { + dict.extend(int(operand)); + } + dict.extend([12, 30]); + dict.extend(int(charset)); + dict.push(15); + dict.extend(int(char_strings)); + dict.push(17); + dict.extend(int(fd_array)); + dict.extend([12, 36]); + dict.extend(int(fd_select)); + dict.extend([12, 37]); + dict +} + +fn font_dict(private: Option<(usize, usize)>) -> Vec { + let mut dict = Vec::new(); + if let Some((size, offset)) = private { + dict.extend(int(size)); + dict.extend(int(offset)); + dict.push(18); + } + dict +} + +/// The CID-keyed CFF program. The second font dict is always empty; the first has a Private +/// DICT only when `first_has_private`. +fn cid_cff(first_has_private: bool) -> Vec { + let header = vec![1, 0, 4, 1]; + let names = index(&[b"T"]); + let strings = index(&[b"Adobe", b"Identity"]); + let global_subrs = index(&[]); + // Format 0: the CIDs of glyphs 1 and 2. + let charset = vec![0, 0, 1, 0, 2]; + // Format 0: the font dict of each glyph. + let fd_select = vec![0, 0, 0, 1]; + // 0 0 rmoveto 500 0 rlineto 0 500 rlineto -500 0 rlineto endchar. + let square: &[u8] = &[139, 139, 21, 28, 1, 244, 139, 5, 139, 28, 1, 244, 5, 28, 254, 12, 139, 5, 14]; + let char_strings = index(&[&[14], square, square]); + // defaultWidthX 0. + let mut private = int(0); + private.push(20); + + let top_len = index(&[&top_dict(0, 0, 0, 0)]).len(); + let charset_at = header.len() + names.len() + top_len + strings.len() + global_subrs.len(); + let fd_select_at = charset_at + charset.len(); + let char_strings_at = fd_select_at + fd_select.len(); + let fd_array_at = char_strings_at + char_strings.len(); + let first_len = font_dict(first_has_private.then_some((0, 0))).len(); + let private_at = fd_array_at + index(&[&vec![0; first_len], &[]]).len(); + let first = font_dict(first_has_private.then_some((private.len(), private_at))); + let fd_array = index(&[&first, &[]]); + let top = index(&[&top_dict(charset_at, fd_select_at, char_strings_at, fd_array_at)]); + + let mut out = header; + for part in [names, top, strings, global_subrs, charset, fd_select, char_strings, fd_array, private] { + out.extend(part); + } + out +} + +/// A page showing the hex string `codes` at 60 pt in an Identity-H Type0 font over `program` +/// (no `/FontFile3` when `program` is empty). +fn page(program: &[u8], codes: &str) -> Vec { + let font_file = if program.is_empty() { "" } else { " /FontFile3 8 0 R" }; + let body = format!("BT /F1 60 Tf 20 30 Td <{codes}> Tj ET"); + let mut stream = format!("<< /Subtype /CIDFontType0C /Length {} >>\nstream\n", program.len()).into_bytes(); + stream.extend_from_slice(program); + stream.extend_from_slice(b"\nendstream"); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 120] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + b"<< /Type /Font /Subtype /Type0 /BaseFont /ABCDEF+Synthetic /Encoding /Identity-H /DescendantFonts [6 0 R] >>".to_vec(), + b"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /ABCDEF+Synthetic /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /DW 1000 >>".to_vec(), + format!("<< /Type /FontDescriptor /FontName /ABCDEF+Synthetic /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 /CapHeight 1000 /StemV 80{font_file} >>").into_bytes(), + stream, + ]; + + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +fn pixels(bytes: Vec) -> Vec { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(image.error.is_none(), "{:?}", image.error); + image.rgba.to_vec() +} + +fn inked(rgba: &[u8]) -> usize { + rgba.chunks(4).filter(|p| p.first().is_some_and(|r| *r < 128)).count() +} + +#[test] +fn glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one() { + let first = pixels(page(&cid_cff(true), "0001")); + // The square is 30 pt on a side at 60 pt: 900 pixels at scale 1. + assert!(inked(&first) >= 800, "the glyph of the usable font dict drew {} pixels", inked(&first)); + let both = pixels(page(&cid_cff(true), "00010002")); + assert!(both == first, "the glyph of the empty font dict drew something, or the first did not draw"); +} + +/// With no usable font dict the program is not read, as before, and the page still renders. +#[test] +fn a_program_with_no_usable_font_dict_draws_no_square() { + let rgba = pixels(page(&cid_cff(false), "00010002")); + let square = pixels(page(&cid_cff(true), "0001")); + assert!(rgba != square, "a font dict without a Private DICT drew its glyph"); + // The page renders exactly as it does with no embedded program at all. + assert!(rgba == pixels(page(&[], "00010002")), "the program was kept although no font dict is usable"); +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index d472578f4d..1a50693fb2 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod cid_cff_unusable_font_dict; mod cid_fallback_query; mod fill_stroke_knockout; mod inline_image_ei; @@ -16,5 +17,7 @@ mod stray_q_in_a_form; mod stroke_degenerate_cubic; mod stroke_zero_end_tangent; mod substitute_widths; +mod symbol_mt_builtin_encoding; mod tiling_cell_placement; mod type3_text_clip; +mod widgets_above_annotations; diff --git a/crates/render/tests/it/symbol_mt_builtin_encoding.rs b/crates/render/tests/it/symbol_mt_builtin_encoding.rs new file mode 100644 index 0000000000..fd9d0d28ca --- /dev/null +++ b/crates/render/tests/it/symbol_mt_builtin_encoding.rs @@ -0,0 +1,113 @@ +//! A non-embedded font named `SymbolMT` (the name Symbol has as a TrueType font) or a style +//! variant such as `Symbol,Bold` is the standard Symbol font, and a non-embedded Symbol draws +//! through its built-in encoding (ISO 32000-2 §9.6.2.2), even when the font dictionary +//! declares `/WinAnsiEncoding` and the nonsymbolic flag: code 0x6D (`m`) is `mu`, as for +//! `/Symbol` itself (hayro-interpret patch (98)). + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// A page showing "abmp" at 40 pt in a non-embedded font `font` (the inside of its dictionary). +fn page(font: &str) -> Vec { + page_with(font, None) +} + +/// [`page`] with `program` as object 6, a `/FontFile2` stream the font descriptor can point at. +fn page_with(font: &str, program: Option<&[u8]>) -> Vec { + let body = "BT /F1 40 Tf 10 30 Td (abmp) Tj ET"; + let mut objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + format!("<< /Type /Font {font} >>").into_bytes(), + ]; + if let Some(program) = program { + objects.push([format!("<< /Length {} >>\nstream\n", program.len()).into_bytes(), program.to_vec(), b"\nendstream".to_vec()].concat()); + } + + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +fn pixels(bytes: Vec) -> Vec { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(image.error.is_none(), "{:?}", image.error); + image.rgba.to_vec() +} + +/// A TrueType `name` with WinAnsiEncoding, nonsymbolic flags and no font program, as written +/// by spreadsheet exports. +fn win_ansi_true_type(name: &str) -> String { + format!( + "/Subtype /TrueType /BaseFont /{name} /Encoding /WinAnsiEncoding /FontDescriptor << /Type /FontDescriptor /FontName /{name} /Flags 32 /FontBBox [0 -216 1113 693] /ItalicAngle 0 /Ascent 1005 /Descent -216 /CapHeight 693 /StemV 60 >>" + ) +} + +#[test] +fn symbol_mt_draws_symbols_through_the_built_in_encoding() { + let symbol = pixels(page("/Subtype /Type1 /BaseFont /Symbol")); + let latin = pixels(page("/Subtype /Type1 /BaseFont /Helvetica")); + assert!(symbol != latin, "the Symbol stand-in drew the Latin letters"); + for name in ["SymbolMT", "ABCDEF+SymbolMT", "SymbolMT,Bold", "Symbol,Italic", "Symbol-Bold"] { + assert!(pixels(page(&win_ansi_true_type(name))) == symbol, "{name} did not draw as Symbol"); + } + let type1 = "/Subtype /Type1 /BaseFont /SymbolMT /Encoding /WinAnsiEncoding"; + assert!(pixels(page(type1)) == symbol, "a Type1 SymbolMT did not draw as Symbol"); +} + +/// The number of dark pixels on a page. +fn inked(rgba: &[u8]) -> usize { + rgba.chunks(4).filter(|p| p.first().is_some_and(|r| *r < 128)).count() +} + +/// Only the Symbol name itself, alone or with a style, counts: a font whose name merely starts +/// with it, or names another font after the separator, keeps its declared encoding and its +/// Latin stand-in, drawing the same letters as Helvetica. +#[test] +fn a_name_that_only_starts_with_symbol_is_not_symbol() { + let latin = pixels(page("/Subtype /Type1 /BaseFont /Helvetica")); + assert!(inked(&latin) > 100, "the Latin reference drew no text"); + for name in ["Symbolic", "SymbolPS", "Symbol-Helvetica", "SymbolMT,Helvetica"] { + assert!(pixels(page(&win_ansi_true_type(name))) == latin, "{name} did not draw the Latin letters"); + } +} + +/// A `/Differences` array still applies to a SymbolMT: code 0x6D named `alpha` draws alpha, as +/// for `/Symbol` with the same differences, not the built-in encoding's `mu`. +#[test] +fn symbol_mt_applies_its_differences() { + let symbol = pixels(page("/Subtype /Type1 /BaseFont /Symbol")); + let alpha = pixels(page("/Subtype /Type1 /BaseFont /Symbol /Encoding << /Differences [109 /alpha] >>")); + assert!(alpha != symbol, "the differences changed nothing"); + let font = win_ansi_true_type("SymbolMT") + .replace("/Encoding /WinAnsiEncoding", "/Encoding << /BaseEncoding /WinAnsiEncoding /Differences [109 /alpha] >>"); + assert!(pixels(page(&font)) == alpha, "SymbolMT did not draw the alpha its differences name"); +} + +/// An embedded font named SymbolMT draws its own program, not the Symbol stand-in. +#[test] +fn an_embedded_symbol_mt_draws_its_own_program() { + let program = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf"); + let embedded = |name: &str| { + let font = win_ansi_true_type(name).replace(" >>", " /FontFile2 6 0 R >>"); + pixels(page_with(&font, Some(program))) + }; + let own = embedded("ABCDEF+JetBrainsMono"); + assert!(inked(&own) > 100, "the embedded program drew no text"); + assert!(embedded("ABCDEF+SymbolMT") == own, "an embedded SymbolMT did not draw its own glyphs"); +} diff --git a/crates/render/tests/it/widgets_above_annotations.rs b/crates/render/tests/it/widgets_above_annotations.rs new file mode 100644 index 0000000000..7800fa05cf --- /dev/null +++ b/crates/render/tests/it/widgets_above_annotations.rs @@ -0,0 +1,74 @@ +//! A contributor-original synthetic page: a check box widget listed before a filled square +//! comment that covers it. Form fields' widgets are drawn after the page's other annotations +//! (hayro-interpret patch (86)), so the check box shows; the comment covered it before. + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// A 200 × 200 pt page whose `/Annots` lists `order` of: `W`, a check box drawing a green box, +/// `S`, a square comment drawing a black one over it, and `R`, a larger red square comment. +fn pdf(order: &str) -> Vec { + let annot = |c: char| match c { + 'W' => "6 0 R", + 'S' => "7 0 R", + _ => "8 0 R", + }; + let annots: Vec<&str> = order.chars().map(annot).collect(); + let form = |rgb: &str| { + let content = format!("{rgb} rg 0 0 100 100 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >>\nstream\n{content}\nendstream", content.len()) + }; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{}] >>", annots.join(" ")), + form("0 1 0"), + form("0 0 0"), + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (box) /F 4 /Rect [50 50 150 150] /AS /Off /AP << /N << /Off 4 0 R /Yes 4 0 R >> >> >>" + .to_string(), + "<< /Type /Annot /Subtype /Square /F 4 /C [0 0 0] /Rect [40 40 160 160] /AP << /N 5 0 R >> >>".to_string(), + "<< /Type /Annot /Subtype /Square /F 4 /C [1 0 0] /Rect [30 30 170 170] /AP << /N 9 0 R >> >>".to_string(), + form("1 0 0"), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +/// The colour at the page's centre. +fn centre(order: &str) -> [u8; 3] { + at(order, 100, 100) +} + +/// The colour at (`x`, `y`) px from the top left. +fn at(order: &str, x: usize, y: usize) -> [u8; 3] { + let mut renderer = PageRenderer::new(Arc::new(pdf(order)), RenderConfig::default()); + let out = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + let at = (y * out.width as usize + x) * 4; + let px = out.rgba.get(at..at + 3).expect("the centre lies on the page"); + [px[0], px[1], px[2]] +} + +#[test] +fn a_widget_is_drawn_above_the_comments_on_its_page() { + assert_eq!(centre("WS"), [0, 255, 0], "a check box listed before a comment over it"); + assert_eq!(centre("SW"), [0, 255, 0], "a check box listed after it"); + // Comments keep their `/Annots` order among themselves. + assert_eq!(centre("RS"), [0, 0, 0], "the black square listed last is on top"); + assert_eq!(centre("SR"), [255, 0, 0], "the red square listed last is on top"); + // And with a widget among them: (45, 45) is inside both squares, outside the check box. + assert_eq!(at("WRS", 45, 45), [0, 0, 0], "a widget first: the black square listed last is on top"); + assert_eq!(at("WSR", 45, 45), [255, 0, 0], "a widget first: the red square listed last is on top"); + assert_eq!(centre("WSR"), [0, 255, 0], "and the check box above both"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c4751fb367..34e282ea21 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -561,8 +561,6 @@ fn dialog_properties(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending } row(ui, "Application", i.creator.clone().unwrap_or_default()); row(ui, "PDF producer", i.producer.clone().unwrap_or_default()); - row(ui, "Created", i.created.clone().unwrap_or_default()); - row(ui, "Modified", i.modified.clone().unwrap_or_default()); } PropsTab::InitialView => { use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 64ce2e115f..45d4c592d3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -734,8 +734,8 @@ milestone = "M2" status = "partial" commands = [] tools = ["page_render"] -evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::a_shrunk_stencil_mask_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged", "crates/render/src/raster/tests.rs::a_page_without_a_media_box_is_letter"] -notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes, a 1-bit /ImageMask text layer included (it was nearest-neighbour, which dropped them). Enlarged by more than 1%, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. A page with no `/MediaBox`, or one without area, is US Letter in the renderer, the page model and the inspector alike. Not measured against Acrobat" +evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::a_shrunk_stencil_mask_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged", "crates/render/src/raster/tests.rs::a_font_without_a_subtype_is_drawn_by_its_base_font", "crates/render/src/raster/tests.rs::a_page_without_a_media_box_is_letter"] +notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes, a 1-bit /ImageMask text layer included (it was nearest-neighbour, which dropped them). Enlarged by more than 1%, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. A font dictionary with a missing or empty `/Subtype` is drawn as a Type 1 font by its `/BaseFont`. A page with no `/MediaBox`, or one without area, is US Letter in the renderer, the page model and the inspector alike. Not measured against Acrobat" [[feature]] id = "view.render-shadings-patterns" @@ -782,8 +782,8 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = [] -notes = "hayro bootstrap with vendored patches. Text rendering modes follow ISO 32000-2 §9.3.6 for Type 3 fonts: 3 and 7 render nothing, the others render the glyph descriptions filled (never stroked), and 4 to 7 add nothing to the clipping path (hayro-interpret patches 42 and 64). Coverage is synthetic fixtures; no fidelity comparison across a Type 3 corpus yet" +evidence = ["crates/render/src/raster/tests/type3_proc_widths.rs::a_type3_font_without_widths_advances_by_its_glyph_procedures", "crates/render/src/raster/tests/type3_proc_widths.rs::widths_the_font_gives_are_kept", "crates/render/src/raster/tests/type3_proc_widths.rs::a_type3_font_without_widths_is_reported"] +notes = "hayro bootstrap with vendored patches. Text rendering modes follow ISO 32000-2 §9.3.6 for Type 3 fonts: 3 and 7 render nothing, the others render the glyph descriptions filled (never stroked), and 4 to 7 add nothing to the clipping path (hayro-interpret patches 42 and 64). A font without /Widths advances each glyph by its glyph procedure's d0/d1 width (§9.6.4) in rendering and text alike, and the page reports a render warning that the font's /Widths is missing (patch 90). Coverage is synthetic fixtures; no fidelity comparison across a Type 3 corpus yet" [[feature]] id = "view.render-cid-fonts" @@ -794,8 +794,8 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster/tests.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster/tests.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster/tests.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/raster/tests.rs::non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face", "crates/engine/src/tests.rs::installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export", "crates/render/src/text/tests.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf/tests.rs::identity_to_unicode_names_read_codes_as_unicode", "crates/render/src/raster/tests.rs::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen", "crates/render/src/raster/tests.rs::a_bfchar_cmap_reads_codes_of_its_codespace_length"] -notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; the desktop viewer can fall back to an installed CJK face for GB1, CNS1, Korea1 and Japan1, including GBK-EUC-H TrueType CID fonts without ToUnicode. Windows preserves installed SimSun, SimHei, KaiTi and FangSong families, including subset-prefixed Founder aliases. Combine thumbnails and accessibility figure previews use the same on-screen fallback. System faces are display-only, disabled by PDFCRAFT_SYSTEM_FONTS=0, and not used by export, printing, OCR or automation; machines without a candidate and web builds remain unsupported. A `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters. A non-embedded Identity font whose `/ToUnicode` gives a soft hyphen (U+00AD, as Windows exporters write Arial's hyphen) draws the standard substitute's hyphen (#5888). Any other character the substitute lacks still falls back to the CID taken as one of the substitute's glyph indexes, an unrelated glyph (#3529, #5726). An embedded CMap that writes its CID mappings as `bfchar` reads each code at the length its codespace ranges give it (ISO 32000-2 §9.7.6.2), so a two-byte code is one glyph, not two (hayro-cmap patch 4)" +evidence = ["crates/render/src/raster/tests.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster/tests.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster/tests.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/raster/tests.rs::non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face", "crates/engine/src/tests.rs::installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export", "crates/render/src/text/tests.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf/tests.rs::identity_to_unicode_names_read_codes_as_unicode", "crates/render/src/raster/tests.rs::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen", "crates/render/src/raster/tests/non_embedded_arial.rs::an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name", "crates/render/src/raster/tests/non_embedded_arial.rs::a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name", "crates/render/src/raster/tests/non_embedded_arial.rs::codes_of_other_fonts_are_not_read_in_the_macintosh_order", "crates/render/src/raster/tests.rs::a_bfchar_cmap_reads_codes_of_its_codespace_length"] +notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; the desktop viewer can fall back to an installed CJK face for GB1, CNS1, Korea1 and Japan1, including GBK-EUC-H TrueType CID fonts without ToUnicode. Windows preserves installed SimSun, SimHei, KaiTi and FangSong families, including subset-prefixed Founder aliases. Combine thumbnails and accessibility figure previews use the same on-screen fallback. System faces are display-only, disabled by PDFCRAFT_SYSTEM_FONTS=0, and not used by export, printing, OCR or automation; machines without a candidate and web builds remain unsupported. A `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters. A non-embedded Identity font whose `/ToUnicode` gives a soft hyphen (U+00AD, as Windows exporters write Arial's hyphen) draws the standard substitute's hyphen (#5888). A non-embedded Arial or Times New Roman (`CIDFontType2`; ArialMT, Arial-BoldMT, Arial,Bold, TimesNewRomanPSMT, TimesNewRoman,Bold and their other faces) whose codes no Unicode route draws is read as glyph indexes of the author's font, whose first 258 glyphs follow the standard Macintosh order: the substitute draws each glyph by that name, .notdef when it has none (#5889, #5726, #6449). Its text still can't be selected, searched or copied without a `/ToUnicode`, Courier New and other faces whose numbering isn't confirmed aren't read this way, and Arial and Times New Roman glyphs past the first 258 (Cyrillic, Greek) and every other font's characters the substitute lacks still fall back to the CID taken as one of the substitute's glyph indexes, an unrelated glyph (#3529, #5726). An embedded CMap that writes its CID mappings as `bfchar` reads each code at the length its codespace ranges give it (ISO 32000-2 §9.7.6.2), so a two-byte code is one glyph, not two (hayro-cmap patch 4)" [[feature]] id = "view.render-image-codecs" @@ -1335,7 +1335,7 @@ status = "shipped" commands = [] tools = ["doc_info", "layer_set"] evidence = ["crates/render/src/raster/tests.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits", "crates/automation/tests/it/automation.rs::layers_and_hidden_comments_change_what_page_render_draws", "crates/render/src/raster/tests.rs::the_view_auto_state_sets_a_groups_state_on_screen", "crates/render/src/raster/tests.rs::layer_toggles_and_print_renders_are_not_the_view_auto_state", "crates/render/src/raster/tests.rs::malformed_view_auto_state_is_ignored", "crates/render/src/inspect/tests.rs::the_layers_panel_presents_the_order_tree", "crates/ui-egui/tests/it/layer_actions.rs::the_layers_panel_shows_the_order_tree"] -notes = "layer_set shows or hides a layer by name (every group with that name, as CAD exporters repeat names) or by position, and page_render draws with the document's layer visibility. Still missing: Reset to initial visibility (view.layers-reset), layer properties, and /OC on annotations (#2176). The default configuration's auto state for the View event (/AS, ISO 32000-2 8.11.4.4) sets the groups it manages from their /Usage /View /ViewState as the document opens, on screen and in the panel, and toggles still override it; renders for printing and print previews don't apply it. Not yet: the /Zoom, /User and /Language categories, and the Print event's auto state when printing. The panel presents the default configuration's /Order (ISO 32000-2 Table 99) as a tree: a nested array one level deeper, a leading text string as a label that can't be toggled. Groups /Order leaves out aren't listed but still draw in their state, and a document without /Order lists none, the default Table 99 gives; doc_info reports the rows as layer_order. Not yet: /ListMode /VisiblePages." +notes = "layer_set shows or hides a layer by name (every group with that name, as CAD exporters repeat names) or by position, and page_render draws with the document's layer visibility. Still missing: Reset to initial visibility (view.layers-reset), layer properties, and /OC on annotations (#2176). The default configuration's auto state for the View event (/AS, ISO 32000-2 8.11.4.4) sets the groups it manages from their /Usage /View /ViewState as the document opens, on screen and in the panel, and toggles still override it; renders for printing and print previews don't apply it; printing takes the Print event's auto state instead (print.print-ready-pdf). Not yet: the /Zoom, /User and /Language categories. The panel presents the default configuration's /Order (ISO 32000-2 Table 99) as a tree: a nested array one level deeper, a leading text string as a label that can't be toggled. Groups /Order leaves out aren't listed but still draw in their state, and a document without /Order lists none, the default Table 99 gives; doc_info reports the rows as layer_order. Not yet: /ListMode /VisiblePages." [[feature]] id = "view.layers-reset" @@ -3538,8 +3538,8 @@ milestone = "M3" status = "shipped" commands = ["file.properties"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft", "crates/organize/src/tests.rs::document_info_falls_back_to_the_xmp_packet", "crates/engine/src/tests.rs::document_information_stated_only_in_xmp_is_shown", "crates/ui-egui/tests/it/dialog_chrome.rs::document_properties_values_line_up_and_long_fields_wrap", "crates/ui-egui/tests/it/dialog_chrome.rs::space_audit_tab_measures_the_file_and_stays_in_the_card", "crates/render/src/inspect/tests.rs::creation_and_modification_dates_are_read_for_document_properties"] -notes = "Description, Initial View, Security, Fonts, Custom (organize.custom-properties) and Advanced, plus a Space Audit tab Acrobat does not have (its audit is only in the PDF Optimizer); the audit runs when the tab opens and again after an edit. Title, Author, Subject and Keywords (and Creator, Producer in doc_info) come from /Info, else from the XMP packet (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) for files that state them only there; edits write both. Description and doc_info show the /Info CreationDate and ModDate as written (YYYY-MM-DD HH:MM, not converted to local time; #5534). Missing: the dates from XMP (xmp:CreateDate, xmp:ModifyDate) when /Info has none, editing them, a UTF-16 XMP packet isn't read, and /Info kept only in an older revision's trailer isn't looked up" +evidence = ["crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft", "crates/organize/src/tests.rs::document_info_falls_back_to_the_xmp_packet", "crates/engine/src/tests.rs::document_information_stated_only_in_xmp_is_shown", "crates/ui-egui/tests/it/dialog_chrome.rs::document_properties_values_line_up_and_long_fields_wrap", "crates/ui-egui/tests/it/dialog_chrome.rs::space_audit_tab_measures_the_file_and_stays_in_the_card"] +notes = "Description, Initial View, Security, Fonts, Custom (organize.custom-properties) and Advanced, plus a Space Audit tab Acrobat does not have (its audit is only in the PDF Optimizer); the audit runs when the tab opens and again after an edit. Title, Author, Subject and Keywords (and Creator, Producer in doc_info) come from /Info, else from the XMP packet (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) for files that state them only there; edits write both. Missing: a UTF-16 XMP packet isn't read, and /Info kept only in an older revision's trailer isn't looked up" [[feature]] id = "organize.edit-metadata" @@ -8017,8 +8017,8 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_verify"] -evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/preflight/src/tests.rs::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_mistyped_properties_name_their_type", "crates/preflight/src/tests.rs::xmp_properties_are_known_by_namespace_uri_and_named_as_written", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] -notes = "PDF/A-2b/3b structural rules: identification, the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs and their structures; the tiff, exif and aux namespaces, whose tables XMP Part 2 defers to CIPA DC-010, are not yet checked), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." +evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/preflight/src/tests.rs::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_exif_schemas_have_their_value_types", "crates/preflight/src/tests.rs::xmp_exif_properties_are_known_by_namespace_uri", "crates/preflight/src/tests.rs::xmp_extension_schema_descriptions_are_complete", "crates/preflight/src/tests.rs::xmp_extension_findings_say_where_they_are", "crates/preflight/src/tests.rs::xmp_extension_naming_entries_are_not_empty", "crates/preflight/src/tests.rs::xmp_mistyped_properties_name_their_type", "crates/preflight/src/tests.rs::xmp_properties_are_known_by_namespace_uri_and_named_as_written", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/preflight/src/tests.rs::implementation_limits_are_reported", "crates/preflight/src/tests.rs::implementation_limits_reach_the_trailer_split_content_deep_values_and_huge_boxes", "crates/preflight/src/tests.rs::implementation_limits_cover_reals_and_cids", "crates/preflight/src/tests.rs::separation_and_devicen_spaces_are_checked", "crates/preflight/src/tests.rs::separation_and_devicen_checks_follow_references_depth_and_nchannel", "crates/preflight/src/tests.rs::file_header_is_checked_and_rewritten", "crates/preflight/src/tests.rs::extended_graphics_states_are_checked", "crates/preflight/src/tests.rs::graphics_state_scan_reads_each_object_once_whatever_the_generation", "crates/preflight/src/tests.rs::trailer_file_identifier_is_checked_and_added", "crates/preflight/src/tests.rs::set_state_no_op_and_named_actions_are_checked"] +notes = "PDF/A-2b/3b structural rules: the file header (6.1.2), identification, the trailer file identifier (6.1.3), the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs, and tiff, exif and aux from the September 2005 XMP Specification, with their structures), XMP extension schema descriptions (6.6.2.3.3, after PDF Association TechNote 0009 section 4: required entries of the schema, property, value type and field descriptions, none of the naming entries empty, and the five required prefixes; that a property's custom value type has its own pdfaSchema:valueType description is not yet checked), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, graphics states (6.2.5: TR, TR2, HTP, halftone types, names and transfer functions with the primary components of ISO 32000-1 10.5.5.6, rendering intents), annotations, actions (with SetState, NOP and Named actions other than the four page moves), images, embedded files, the implementation limits of 6.1.13 (integers, reals, strings, names, CIDs and page boundaries, ISO 32000-1 Annex C), Separation and DeviceN spaces (6.2.4.4: one definition per colorant name, spot colorants in /Colorants; a device alternate without a matching output intent is not yet reported), and transparency (6.2.10: blend modes ISO 32000-1 defines, and a blending colour space on pages with transparency when there is no PDF/A output intent). Not yet: content-stream operator checks, a transparency group blending colour space checked against the output intent, PDF/A-1 and -4, a veraPDF comparison." [[feature]] id = "optimize.pdfa-convert" @@ -8029,8 +8029,8 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_convert"] -evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] -notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship. Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1. On an encrypted document it needs the owner password, as Protect and Remove security do, since it removes the encryption (#3921)." +evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::file_header_is_checked_and_rewritten", "crates/engine/src/tests.rs::save_as_pdfa_saves_a_compliant_file_header", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document", "crates/preflight/src/tests.rs::trailer_file_identifier_is_checked_and_added"] +notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship, the file header (a full save at version 1.7 at most), a missing or malformed file identifier. Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1. On an encrypted document it needs the owner password, as Protect and Remove security do, since it removes the encryption (#3921)." [[feature]] id = "optimize.pdfa-accessible-levels" diff --git a/vendor/README.md b/vendor/README.md index 8e2ceb8228..008ea46083 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,8 +39,8 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too; (76) `InterpreterSettings::view_auto_state` (on by default): the default configuration's auto state for the View event (`/AS` usage applications with `/Event /View` and `/View` in `/Category`) sets each managed group from its `/Usage /View /ViewState` before `ocg_overrides`, ON only if every recommendation is ON (ISO 32000-2 §8.11.4.4); `/Zoom`, `/User` and `/Language` give no recommendation; at most 256 applications, 4096 `/OCGs` entries and 16 categories each are read; off for print renders | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render); (76) `raster::tests::the_view_auto_state_sets_a_groups_state_on_screen`, `raster::tests::layer_toggles_and_print_renders_are_not_the_view_auto_state` and `raster::tests::malformed_view_auto_state_is_ignored` (pdfcraft-render) | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (21) an image that does not request interpolation and is drawn below its resolution, outside a Type 3 glyph, is shrunk by area averaging (`area_shrink`: each device pixel the coverage-weighted mean of the samples it covers, in 16.16 fixed point whose weights sum to exactly one, premultiplied when the image has a mask; a soft mask no larger than the image is first sampled onto the image's grid by `mask_on_image_grid` (nearest-neighbour, or bilinearly when the mask asks for `/Interpolate`, so its edge stays as smooth as an enlarged mask's), so its colour and mask are averaged together instead of the averaged colour of transparent samples showing under a mask layer at the mask's edges) to its snapped device rectangle, instead of nearest-neighbour: `/Interpolate` asks for smoothing only when an image is enlarged (ISO 32000-2 §8.9.5.3), and nearest-neighbour kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes (sampling them again at a fractional offset dropped more). PicScale's `Box` and `Area` filters were not used: shrinking, both average a window four target pixels wide. On a 13-page 300 dpi scan every rendered sample at 96, 120 and 192 dpi is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). A snapped image whose shrink failed keeps the full transform, so it is not drawn at its source size. The shrink never enlarges: a side drawn at or above its resolution (an image shrunk across and stretched down, as a 204 × 98 dpi fax is) keeps its samples for the sampler. Each target row averages the whole source rows it covers (contiguous, cheap) and is then shrunk across with taps worked out once per image, which made the shrink about three times faster (a 300 dpi page at 96 dpi: 25 ms, nearest-neighbour 11 ms); working a row at a time (`area_shrink_rows`) keeps its work memory to a few rows. An image with a soft mask, a stencil's ink included, is premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`), never as a full-resolution RGBA copy: a 10718 × 13701 stencil shown at page size peaked at 1.16 GB with that copy and peaks at 1.03 GB without it, as with nearest-neighbour. A source with a side over `MAX_RESAMPLING_SOURCE_SIDE` (2^20) keeps (10)'s nearest-neighbour shrink, and `area_shrink` refuses it, so its accumulator row and tap table stay a few times one source row (a 2^27 × 2 image of zeros is a tiny file); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (28) an image of page-scan size (`SMOOTHED_IMAGE_SIDE`: 512 samples or more on its shorter side) that does not request interpolation is drawn with `ImageQuality::Medium` (bilinear, as interpolated images are) when enlarged by more than 1% on either side (`SMOOTHED_IMAGE_SCALE`: a scan drawn at its resolution can come out a few ulps above 1, and blurred by half a pixel at a half-pixel offset), also when the other side is shrunk (the area shrink leaves that side at 1:1), outside a Type 3 glyph: scans rarely set `/Interpolate`, and drawn nearest-neighbour every sample of a zoomed scan became a block, so its text had staircase edges; icons, QR codes and pixel art keep their hard pixels. Bicubic (`High`) looked the same on a zoomed 300 dpi scan and drew four 1024 px tiles at 400% in 267 ms against 50 ms; (31) `RenderSettings::max_coverage` keeps the rasterizer to that many coverage values for the page (vello_cpu patch 1), and a page whose paths it left out says so with `InterpreterWarning::ContentTruncated` | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (21) `raster::tests::non_interpolated_minification_keeps_thin_strokes`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_minification_does_not_bleed_transparent_colour`, `raster::tests::a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified`, `raster::tests::an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified`, `raster::tests::a_shrunk_stencil_mask_keeps_thin_strokes`, `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`, `renderer::area_shrink_tests` (`cargo test -p hayro`); (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (28) `raster::tests::large_non_interpolated_images_are_smoothed_when_enlarged`, `raster::tests::small_non_interpolated_images_keep_hard_pixels_when_enlarged`, `raster::tests::a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched`, `raster::tests::a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp`; (31) `raster::tests::coverage_budget::a_page_past_its_coverage_leaves_the_rest_out_and_says_so` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (76) a CID of an Arial the PDF doesn't embed (`CIDFontType2`, not of an Adobe character collection; `ArialMT`, `Arial-BoldMT`, `Arial,Bold` and the other two faces, not Arial Narrow, Black or Unicode MS) that no Unicode route draws names, through its `/CIDToGIDMap`, the Arial glyph of the standard Macintosh order (read-fonts' `post::DEFAULT_GLYPH_NAMES`, its first 258 glyphs), and a CFF substitute draws its glyph of that name (.notdef when it has none) instead of the CID taken as its own glyph index (#5889); (78) `TilingPattern::instance` numbers each setting of a tiling pattern (`scn`/`SCN`), its copies keeping the number, and `TilingPattern::is_coloured` reports `/PaintType 1`, so a renderer can draw a coloured cell once for every fill of one setting (hayro patch (30)); (82) a soft mask (or `/Mask` image) larger than its image and more than twice as large on a side as the device pixels its image is drawn at is averaged down to that size, rounded up to a power of two, in `ImageXObject::decoded_soft_mask`, and cached there (`DecodedImageKey::shrunk_to`, with the image's raster keyed by the same rounded size); every size is made from one cached intermediate mask at most 4096 on a side, so the full mask is decoded once however many sizes it is drawn at: it bypassed the decoded-image cache and was decoded, copied and staged at full size each time it was drawn. A 2 × 2 image with a 34,862 × 4,332 mask drawn 40 times on one slide (pdf.js `issue16263.pdf`) took about 3.6 s a draw and minutes a page; it now decodes the mask once and draws the page in about 2.5 s. A mask no larger than twice its drawn size keeps its resolution, which the renderer filters as before; `InterpreterCache::decoded_mask_decodes` counts the masks among the decodes; (83) an embedded TrueType program whose `head.indexToLocFormat` holds neither 0 nor 1 is read with the `loca` format its table's length gives (`with_loca_format_fixed`): the glyph reader took anything but 1 as short offsets, so a font marked 256 had its long offsets misread and drew boxes (pdf.js `issue2537r.pdf`); (84) a Type 0 font of an Adobe character collection (Japan1, GB1, CNS1, Korea1) reads a code its `/ToUnicode` has nothing for, or every code when it has none, through the collection's UCS2 CMap, code to CID to Unicode, embedded or not (ISO 32000-2 §9.10.2; `Type0Font::ucs2`, parsed the first time a code needs it): only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts are embedded without a `/ToUnicode`, or with one that maps nothing, had no text to select, find or copy; (85) a Type 0 font's `/ToUnicode` written as a CID CMap (`begincidchar` / `begincidrange`, destinations as numbers, as Word writes it) gives each number as the code's Unicode value for text, as glyph lookup (`map_via_unicode`) already read it and as pdftotext and PDFium extract it; ISO 32000-2 §9.10.3 has `beginbfchar` / `beginbfrange` there, and such text extracted nothing (pdf.js `issue9367`); (86) form fields' widgets are drawn after the page's other annotations, each group in `/Annots` order (`interpret_page`), as MuPDF and PDFium draw them; ISO 32000-2 doesn't order the two: a check box listed before a filled square comment that covers it was hidden under the comment (pdf.js `issue13003.pdf`); (87) a Type 3 font keeps each glyph procedure it decoded (`Type3::decoded_proc`), so a glyph shown again isn't inflated again: a font used as a fill pattern drew one glyph about 650,000 times on a page and decoded its procedure for each; a procedure over 64 KiB isn't kept, and the fonts of one `InterpreterCache` keep at most 32 MiB together (`MAX_CACHED_TYPE3_PROCS`), each kept procedure counting its bytes, its name and 64 bytes besides, as fonts live as long as the cache; a procedure holds its bytes within the page's content budget while the glyph is painted, kept or not (`context::hold_content`), so a page skips the same glyphs once the budget is spent, and one decoded with no more than 64 KiB of budget left, which the budget may have cut short, isn't kept; `InterpreterCache::type3_proc_decodes` counts the procedures decoded; (88) the rule of (76) also reads Times New Roman (`TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT`, `TimesNewRoman,Bold` and the other faces, not Times or Times New Roman MT Std), whose first 258 glyphs follow that order too: in a public form whose embedded Times New Roman subsets keep their glyph indexes, 941 of 942 `/ToUnicode` entries below 258 name that order's character, and its non-embedded `TimesNewRoman,Bold` drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" (#6449); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too; (90) a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`) advances each glyph by the width its glyph procedure sets with `d0` or `d1` (`Type3::proc_width`, ISO 32000-2 9.6.4) instead of 0, read once per code; a font with `/Widths` is unchanged, a code outside `/FirstChar`..`/LastChar` keeping width 0 (Table 110) (PDFBOX-2943: a misspelt `/Widths` key drew every glyph on one spot), and selecting such a font reports `InterpreterWarning::Type3WidthsMissing` (`Font::type3_widths_missing`, checked in `Context::resolve_font`); (91) the decoded-image cache keeps a failed decode for its key, as the warnings it reported, so a picture or stencil mask that doesn't decode (damaged data, a JPX or JBIG2 stream its decoder refuses) isn't decoded again at every draw, tile and render that shares the cache, and each draw still reports what the decode did (`DecodedImageCache::get_or_insert_with`, `ImageXObject::reporting_to`); a failure counts 128 bytes towards the cache's budget, and one the page's JBIG2 allowance may have caused (73) isn't kept (the hayro-syntax comment on `charge_page_reading` says so); (92) the text of a glyph whose name is not in the glyph list reads `uniXXXX` and `uXXXX` to `uXXXXXX` by the Adobe Glyph List Specification (`agl_unicode_from_name`: `uni` and exactly four uppercase hex digits, `u` and four to six, no surrogate, nothing past U+10FFFF), so `uni65`, `u65`, `uni20ac` and `uni+065` read as nothing instead of `e` and `€`; upstream's lenient `unicode_from_name` stays for finding a glyph in a font (TrueType `cmap` lookup, accented standard-font letters), so drawing is unchanged; (93) a CID-keyed CFF font keeps the font dicts it can read: a font dict without a Private DICT (one a subsetter emptied) leaves only the glyphs FDSelect gives it missing, where one such dict dropped the whole font and its text drew with a fallback (ISO 32000-2 §9.9 Table 124, Adobe TN #5176); (94) a font dictionary whose `/Subtype` is missing or the empty name (`/Subtype /`) is read as Type 1, so its `/BaseFont` picks the face (ISO 32000-2 Table 109 requires `/Subtype`; other readers fall back this way): a `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). Any other unknown subtype is still refused; (98) a non-embedded font named `SymbolMT`, or `Symbol`/`SymbolMT` with a `,` or `-` style suffix (`Symbol,Bold`, `Symbol-Bold`; `SYMBOL_STYLES`, so `Symbol-Helvetica` stays another font), is the standard Symbol font and draws through its built-in encoding, so a TrueType `SymbolMT` declaring `/WinAnsiEncoding` draws `mu` for code 0x6D, not `m` (ISO 32000-2 §9.6.2.2); (99) the mesh sampler walks each row of a triangle only across the columns where that row's sample points can lie in it, not its whole box (a row with none still costs a visit): thin triangles of the fixed 19 × 19 patch grid lying across the pixel grid (a narrow curved band of tensor patches) have boxes many times their area, and a mesh painted through a path the size of its own box spent the visit budget before its last patches and left them unpainted; (100) a Type 1 or Type 3 font glyph name that only numbers its glyph (`C39`, `MT72`, `G4E`: one to three letters, then decimal or hexadecimal digits) and names the very code it is shown with reads as that code's Latin-1 character (controls excepted; in a Type 3 font, which already reads codes 32-126 by the code, above 126), in a font where every such name names its own code (`numbered_names_name_their_codes`, worked out once per font); a font with any numbered name at another code (`/C1` at 233, review on #3239; `/Differences [1 /C70 …]`) or at a C0 control code (a subset counting its glyphs from code 1, `/Differences [1 /C1 /C2 …]`, where `/C65` is the 65th glyph used) keeps reading them as nothing (#3170); (101) a symbolic TrueType font with no encoding reads a code as Mac OS Roman in text extraction when its Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4) and that glyph sits where the standard Macintosh glyph order (`post` version 1.0) puts the code's Mac OS Roman character, for a font whose `post` table names no glyphs, that names no base encoding (an unknown name included) and that isn't Symbol or ZapfDingbats (§9.6.6.1), after `/Differences` and before the WinAnsi last resort; Windows subsets in the same order keep their WinAnsi reading, and glyph selection is unchanged; (102) `OutlineGlyph::vertical_glyph_width`: a vertical-writing Type 0 glyph's width w0 from `/W` or `/DW`, its extent across the column (ISO 32000-2 §9.7.4.3), which `advance_width` (the horizontal advance, 0 in vertical writing) does not give; (103) `InterpreterSettings::view_auto_state` (on by default): the default configuration's auto state for the View event (`/AS` usage applications with `/Event /View` and `/View` in `/Category`) sets each managed group from its `/Usage /View /ViewState` before `ocg_overrides`, ON only if every recommendation is ON (ISO 32000-2 §8.11.4.4); `/Zoom`, `/User` and `/Language` give no recommendation; at most 256 applications, 4096 `/OCGs` entries and 16 categories each are read; off for print renders | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) ; (76) `raster::tests::non_embedded_arial::{an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (78) `raster::tests::{fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}` (pdfcraft-render); (82) `raster::tests::a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once`, `raster::tests::an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size`, `raster::tests::decoded_images_serve_every_size_they_are_drawn_at` (pdfcraft-render); (83) `raster::tests::truetype_loca_format::a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs` (pdfcraft-render); (84) `text::tests::an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap`, `every_adobe_collection_reads_through_its_own_ucs2_cmap` (GB1, CNS1, Korea1, Identity-V, `/H` over CFF) and `a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap` (pdfcraft-render); (85) `text::tests::{a_tounicode_written_as_cid_ranges_gives_text, a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code}` (pdfcraft-render); (86) `widgets_above_annotations::a_widget_is_drawn_above_the_comments_on_its_page` (pdfcraft-render `tests/it`); (87) `raster::tests::type3_proc_cache::{a_type3_glyph_shown_again_decodes_its_procedure_once, a_procedure_over_64_kib_is_decoded_for_each_copy, the_procedures_one_cache_keeps_stop_at_32_mib, fonts_with_the_same_glyph_name_keep_their_own_procedures}` (pdfcraft-render); (88) `raster::tests::non_embedded_arial::{a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render); (90) `raster::tests::type3_proc_widths::{a_type3_font_without_widths_advances_by_its_glyph_procedures, a_code_outside_first_char_to_last_char_still_has_width_zero, widths_the_font_gives_are_kept, a_code_without_a_usable_procedure_width_still_advances_nothing, a_type3_font_without_widths_is_reported}` (pdfcraft-render); (91) `raster::tests::failed_image_decodes::{a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_that_does_not_decode_is_decoded_once_at_each_size, a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once, a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time, a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page}` (pdfcraft-render); (92) `text::tests::agl_unicode_from_name_rule_reads_text` (pdfcraft-render); (93) `cid_cff_unusable_font_dict::{glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one, a_program_with_no_usable_font_dict_draws_no_square}` (pdfcraft-render integration tests); (94) `raster::tests::a_font_without_a_subtype_is_drawn_by_its_base_font` (pdfcraft-render); (98) `symbol_mt_builtin_encoding::{symbol_mt_draws_symbols_through_the_built_in_encoding, a_name_that_only_starts_with_symbol_is_not_symbol, symbol_mt_applies_its_differences, an_embedded_symbol_mt_draws_its_own_program}` (pdfcraft-render integration tests); (99) `raster::tests::thin_patches_of_a_mesh_in_a_tight_path_are_all_painted` (pdfcraft-render); (100) `text::tests::numbered_glyph_names_read_as_their_own_code`, `text::tests::numbered_type3_glyph_names_read_as_their_own_code`, `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text` (pdfcraft-render); (101) `raster::tests::symbolic_truetype_mac_roman_text::{a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman, winansi_codes_of_a_font_in_the_macintosh_order_read_as_before, a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before, a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read, a_font_that_names_its_glyphs_is_not_read_by_their_order, a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading, differences_naming_notdef_clear_the_mac_roman_reading_for_its_code, a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off}` (pdfcraft-render), `pdf::tests::a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman` (pdfcraft-fonts); (102) `text::tests::tracked_vertical_latin_stays_one_line` (pdfcraft-render); (103) `raster::tests::the_view_auto_state_sets_a_groups_state_on_screen`, `raster::tests::layer_toggles_and_print_renders_are_not_the_view_auto_state` and `raster::tests::malformed_view_auto_state_is_ignored` (pdfcraft-render) | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (21) an image that does not request interpolation and is drawn below its resolution, outside a Type 3 glyph, is shrunk by area averaging (`area_shrink`: each device pixel the coverage-weighted mean of the samples it covers, in 16.16 fixed point whose weights sum to exactly one, premultiplied when the image has a mask; a soft mask no larger than the image is first sampled onto the image's grid by `mask_on_image_grid` (nearest-neighbour, or bilinearly when the mask asks for `/Interpolate`, so its edge stays as smooth as an enlarged mask's), so its colour and mask are averaged together instead of the averaged colour of transparent samples showing under a mask layer at the mask's edges) to its snapped device rectangle, instead of nearest-neighbour: `/Interpolate` asks for smoothing only when an image is enlarged (ISO 32000-2 §8.9.5.3), and nearest-neighbour kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes (sampling them again at a fractional offset dropped more). PicScale's `Box` and `Area` filters were not used: shrinking, both average a window four target pixels wide. On a 13-page 300 dpi scan every rendered sample at 96, 120 and 192 dpi is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). A snapped image whose shrink failed keeps the full transform, so it is not drawn at its source size. The shrink never enlarges: a side drawn at or above its resolution (an image shrunk across and stretched down, as a 204 × 98 dpi fax is) keeps its samples for the sampler. Each target row averages the whole source rows it covers (contiguous, cheap) and is then shrunk across with taps worked out once per image, which made the shrink about three times faster (a 300 dpi page at 96 dpi: 25 ms, nearest-neighbour 11 ms); working a row at a time (`area_shrink_rows`) keeps its work memory to a few rows. An image with a soft mask, a stencil's ink included, is premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`), never as a full-resolution RGBA copy: a 10718 × 13701 stencil shown at page size peaked at 1.16 GB with that copy and peaks at 1.03 GB without it, as with nearest-neighbour. A source with a side over `MAX_RESAMPLING_SOURCE_SIDE` (2^20) keeps (10)'s nearest-neighbour shrink, and `area_shrink` refuses it, so its accumulator row and tap table stay a few times one source row (a 2^27 × 2 image of zeros is a tiny file); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (28) an image of page-scan size (`SMOOTHED_IMAGE_SIDE`: 512 samples or more on its shorter side) that does not request interpolation is drawn with `ImageQuality::Medium` (bilinear, as interpolated images are) when enlarged by more than 1% on either side (`SMOOTHED_IMAGE_SCALE`: a scan drawn at its resolution can come out a few ulps above 1, and blurred by half a pixel at a half-pixel offset), also when the other side is shrunk (the area shrink leaves that side at 1:1), outside a Type 3 glyph: scans rarely set `/Interpolate`, and drawn nearest-neighbour every sample of a zoomed scan became a block, so its text had staircase edges; icons, QR codes and pixel art keep their hard pixels. Bicubic (`High`) looked the same on a zoomed 300 dpi scan and drew four 1024 px tiles at 400% in 267 ms against 50 ms; (30) a stencil mask painted with a pattern is drawn under a clip of its device rectangle, padded by two pixels: the pattern in an isolated layer, cut to the stencil by a layer holding it composited with `Compose::DestIn` (drawn without a Type 3 glyph's image rules, as the sub-render it replaces was), instead of through a canvas-sized luminance mask rendered for every draw; and a coloured tiling pattern's cell pixmap, repeated or shown once under patch (20), is drawn once per setting of the pattern and cell scale (`Renderer::tiling_cell`, `TilingCells`, eight entries per renderer, keyed by hayro-interpret patch (78)'s `TilingPattern::instance`) instead of once per fill, where every cell was kept until the page was composited. pdf.js issue 8078 paints 73,047 16 × 16 stencils with one pattern whose cell is 3.5 MB at 36 dpi: it ran out of memory after 5 minutes and now renders in about 5 s. For its tests, `offscreen_draws` counts on each thread the tiling cells drawn and the pixels of every pixmap drawn offscreen (all made by `offscreen_pixmap`), so a page's cost is checked by count, not by time; (31) `RenderSettings::max_coverage` keeps the rasterizer to that many coverage values for the page (vello_cpu patch 1), and a page whose paths it left out says so with `InterpreterWarning::ContentTruncated` | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (21) `raster::tests::non_interpolated_minification_keeps_thin_strokes`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_minification_does_not_bleed_transparent_colour`, `raster::tests::a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified`, `raster::tests::an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified`, `raster::tests::a_shrunk_stencil_mask_keeps_thin_strokes`, `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`, `renderer::area_shrink_tests` (`cargo test -p hayro`); (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (28) `raster::tests::large_non_interpolated_images_are_smoothed_when_enlarged`, `raster::tests::small_non_interpolated_images_keep_hard_pixels_when_enlarged`, `raster::tests::a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched`, `raster::tests::a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp`; (30) `raster::tests::{a_patterned_stencil_mask_paints_only_where_it_inks, a_patterned_stencil_mask_under_a_soft_mask_is_masked_once, a_patterned_stencil_mask_in_a_type3_glyph_keeps_thin_strokes, many_patterned_stencil_masks_draw_no_page_sized_masks, fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}`; (31) `raster::tests::coverage_budget::a_page_past_its_coverage_leaves_the_rest_out_and_says_so` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed; (17) `Image::new_embedded` reads data that starts with a JBIG2 file header (the ID string `0x97 4A 42 32 0D 0A 1A 0A`, flags and an optional page count, D.4) past it, in the organization the header declares (sequential or random access), its segments counted together with the globals' as (8) has them: ISO 32000-1 7.4.7 has `JBIG2Decode` data in the embedded organization (D.3), but some producers write the file header too, and Acrobat and PDFBox draw such streams. The header's ID string was read as a segment header and the image failed (`unknown or reserved segment type`), so the page drew white | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` ; (17) `jbig2::decode::tests::a_stream_with_a_jbig2_file_header_decodes_past_it`, `jbig2::decode::tests::a_truncated_jbig2_file_header_is_an_error`, `jbig2::decode::tests::segments_after_a_jbig2_file_header_count_with_the_globals` (pdfcraft-filters), `raster::tests::a_jbig2_image_with_a_file_header_draws` (pdfcraft-render) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (30) a stream's own crypt filter: `/Crypt` in its chain read as the filter it names, `/Identity` without one, instead of the document's filter and then a filter that can't be decoded, which left such a stream unreadable in any spelling (ISO 32000-2 §7.4.10); (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once; (35) PNG predictors (`lzw_flate::unfilter_png`) are undone a byte row at a time on slices, as the PNG filters are defined, instead of through the bit-level readers TIFF predictors need: a 993 × 936 RGB image took about 63 ms to unfilter whatever its row filters, and now takes about 3 ms (Up) to 16 ms (Paeth) in a release build (#5430); a row ending part-way through a pixel (three 4-bit samples, an odd width) keeps its last byte, where the bit-level readers dropped it and each later row started a byte early, shearing the picture; (36) a stream with no `endstream` left after it is the bytes its `/Length` gives, "how many bytes of the PDF file are used for the stream's data" (ISO 32000-2 7.3.8.2), when the file holds that many (`parse_declared`, tried after the search for `endstream` finds none): a producer that writes `endstrea` after every stream lost every font and page content, and its pages drew blank while other readers draw them; (37) a document that encrypts only its embedded files, authorised when one is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6, Table 25: every encrypting crypt filter is EFOpen, `/EFF` names one, `/StmF` and `/StrF` don't), opens without a password with the pass-through decryptor: its strings and streams are in the clear and nothing drawn reads an embedded file; (38) a page box that encloses no area is read as a missing one: a `/MediaBox` without area (`[0 0 0 0]`) as missing (an inherited one with area, else the default page, US Letter since patch 44), and a `/CropBox` without area, or with none in common with the media box, as missing (an inherited one, else the media box; ISO 32000-2 §14.11.2, Table 33); kept, the visible region had no area, so nothing was drawn and no text extracted (pdf.js `boundingBox_invalid`); (40) the last Flate filter of an image whose PNG predictor (`/Predictor` 10–15) its writer declared but never applied gives the image's samples as they are: when the inflated data is exactly `/Height` rows of the predictor's row length with no filter-type byte before each (where predicted rows would need one more byte each, ISO 32000-2 §7.4.4.4), `untagged_png_rows` takes it for unpredicted samples and reports it (`warn!`), as PDFium, MuPDF and Poppler read it; read with the predictor, each row's first sample was taken for a filter type and the image came out a row short and shifted, drawn in fragments (PDFBOX-3932). `Filter::apply` is given the image's rows for its last filter only; any other length is predicted as before; (41) CCITT `/Rows` above the image's `/Height` decodes to the height too, so the decoder is never asked for more rows than the image has (ISO 32000-2 Table 87): a scan with `/Rows 1084975104` on 2,592 columns had the size check refuse every page, and all 17 drew blank (PDFBOX-771); `/Rows` still counts for a stream decoded without an image's height; (42) TIFF predictor 2 on 8- and 16-bit samples (`lzw_flate::undo_tiff_bytes`) is undone a byte row at a time on slices, each sample added to the same component of the pixel to its left, wrapping at the sample's width (16-bit samples big-endian), instead of through the bit-level readers 1-, 2- and 4-bit samples still need; the bytes are those the bit-level path wrote, and a partial last row is still dropped. A page of 45 full-page 2445 × 4724 8-bit grey images so predicted (PDFBOX-1350) took about 23 s to render at 96 dpi and now takes about 4 s, with the same pixels; (43) a last update cut off before its trailer (the last `startxref` more than 4 KiB from the end of the file, as `pdfcraft-cos` judges the footer, with `N G obj` headers after it: `cut_off_update_follows`) is read by rebuilding the table from object headers (`Pdf::new_with_password` calls `fallback` first, after the table the last `startxref` names has refused a wrong password, and takes the rebuilt table only when it is encrypted exactly when that one is), the latest definition of each object winning, as `pdfcraft-cos` reads such a file; the older table the last `startxref` names is read only when the rebuilt table has no page (a file cut short inside the update, whose new page tree names pages that are not there). The renderer had shown the older revision while the document model read the newer one (PDFBOX-3934: 12 pages against 13; ISO 32000-2 §7.5.6). A `startxref` near the end, or one followed only by data that holds no object header, is read as before; (44) a page with no `/MediaBox`, its own or inherited, or one without area (`[0 0 0 0]`, which now counts as missing, so the page is placed and clipped by the region it is sized by and its content shows), is US Letter (`LETTER`, 612 × 792 pt), not A4, and so is the `base_dimensions` fallback for a visible region without area: ISO 32000-2 Table 31 requires the box and gives no default, other readers use Letter, and PdfCraft's page model and inspector already did (PDFBOX-4001); (45) `root_xref`: when the newest cross-reference section names no earlier one (no `/Prev`, ISO 32000-2 §7.5.6, §7.5.8.2; a hybrid section's `/XRefStm` stream belongs to the same update and doesn't count, §7.5.8.4), the file holds another `startxref`, and the catalog's `/Pages` can't be read, the table is rebuilt by scanning the file (`XRef::repair`) before the page tree is read, so the cut-off page tree is found instead of the page dictionaries `Pages::new_brute_force` turns up (the signed files of PDFBOX-2720 and PDFBOX-2942 showed 2 blank pages for 1, 10, 13 and 1754); `Pdf::rebuilt_unlinked_update` says so, and `pdfcraft-cos` rebuilds under the same conditions | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (30) `raster::tests::a_stream_with_a_crypt_filter_of_its_own_is_drawn`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax); (35) `filter::lzw_flate::pdfcraft_png_predictor_tests` (hayro-syntax: every filter type at 1–16 bits and 1–4 colours against the PNG specification's byte-by-byte definition, a row ending part-way through a pixel, Average and Paeth over 6-byte pixels), `raster::tests::png_predicted_images::a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared`; (36) `raster::tests::a_content_stream_without_endstream_is_drawn_to_its_length` (pdfcraft-render); (37) `inspect::tests::a_document_encrypting_only_its_embedded_files_opens_without_a_password`; (38) `text::tests::text_on_a_page_whose_box_has_no_area_is_extracted` (pdfcraft-render); (40) `filter::lzw_flate::pdfcraft_png_predictor_tests::an_image_whose_rows_carry_no_png_tags_is_read_as_its_samples`, `an_image_whose_rows_carry_png_tags_still_has_its_predictor_undone`, `data_of_neither_length_decodes_as_it_did`, `rows_of_no_bytes_or_of_more_than_memory_holds_do_not_panic` (hayro-syntax), `raster::tests::png_predicted_images::an_image_and_soft_mask_whose_rows_carry_no_png_tags_draw_their_samples`, `an_image_one_byte_short_of_untagged_rows_still_goes_through_the_predictor`; (41) `raster::tests::group4_fax_with_rows_past_its_height_decodes_to_the_image_height`; (42) `filter::lzw_flate::pdfcraft_tiff_predictor_tests` (hayro-syntax: random 8- and 16-bit rows at 1–7 colours and 1–50 columns against the bit-level path, partial and empty rows, and a row counter showing whole-byte samples skip the bit-level path), `raster::tests::tiff_predicted_images`; (43) `raster::tests::an_update_cut_off_before_its_trailer_shows_its_pages_as_the_model_reads_them`, `raster::tests::a_complete_update_reads_as_before`, `raster::tests::trailing_data_without_objects_reads_as_before`, `raster::tests::a_short_cut_off_update_reads_as_before`, `raster::tests::a_cut_off_update_with_obj_followed_by_a_delimiter_is_rebuilt`, `raster::tests::a_cut_off_update_with_obj_followed_by_a_comment_is_rebuilt`, `raster::tests::trailing_data_with_an_object_header_reads_as_the_model_reads_it`, `raster::tests::an_update_cut_off_before_its_new_pages_keeps_the_older_table`, `raster::tests::an_encrypted_file_with_a_cut_off_update_still_needs_its_password`, `raster::tests::a_rebuild_whose_trailer_lacks_the_encryption_dictionary_is_not_taken` (pdfcraft-render); (44) `raster::tests::a_page_without_a_media_box_is_letter`, `raster::tests::raw_crop_unit_floor_preserves_nearly_zero_fallback` (pdfcraft-render), `pdfcraft-model::tests::{a_page_without_a_media_box_is_letter, a_page_whose_media_box_has_no_area_is_letter}`; (45) `inspect::core::unlinked_update_tests::an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt`, `inspect::core::unlinked_update_tests::a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it` (pdfcraft-render) | @@ -97,8 +97,10 @@ Glyph names outside the Adobe Glyph List (#3169, #3170): hayro-interpret's `glyp maps a name by the glyph list specification's full mapping (drop from the first `.`, split on `_`, each part through the list, `uniXXXX…` or `uXXXX`; a `uni` part with any group that isn't a character maps to nothing), so `f_i`, `f_l` and `hyphen.case` read as text. A name that only -numbers the glyph (`C39`, `c251`) still reads as nothing: the code's StandardEncoding character -would be a guess, wrong wherever the font's /Encoding puts the glyph elsewhere (review on #3239). Covered by `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text`. +numbers the glyph (`c251` at 97, `C1` at 233) still reads as nothing: the code's StandardEncoding +character would be a guess, wrong wherever the font's /Encoding puts the glyph elsewhere (review +on #3239). The one exception is patch (100): a font whose numbered names all name their own codes. +Covered by `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text`. Text extraction: hayro-interpret exposes `OutlineGlyph::is_vertical` from a Type0 font’s CMap writing mode. PdfCraft follows negative glyph-space y for WMode 1 so upright vertical columns can be searched (#126). diff --git a/vendor/hayro-interpret/Cargo.lock b/vendor/hayro-interpret/Cargo.lock index c10e174988..fee8cbd0f9 100644 --- a/vendor/hayro-interpret/Cargo.lock +++ b/vendor/hayro-interpret/Cargo.lock @@ -146,9 +146,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.11.3" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", ] @@ -297,6 +297,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "phf" version = "0.13.1" @@ -387,12 +393,13 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.39.2" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" dependencies = [ "bytemuck", "font-types", + "once_cell", ] [[package]] @@ -415,9 +422,9 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skrifa" -version = "0.42.1" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f" dependencies = [ "bytemuck", "read-fonts", diff --git a/vendor/hayro-interpret/src/cache.rs b/vendor/hayro-interpret/src/cache.rs index bbc26e163f..cb885bd6a7 100644 --- a/vendor/hayro-interpret/src/cache.rs +++ b/vendor/hayro-interpret/src/cache.rs @@ -1,10 +1,11 @@ use crate::util::hash128; +use crate::{InterpreterWarning, WarningSinkFn}; use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Null, ObjRef, Object, Stream}; use kurbo::Affine; use std::any::Any; use std::collections::HashMap; use std::collections::hash_map::Entry; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; type CacheMap = HashMap>>; @@ -69,6 +70,14 @@ impl Cache { /// them; when an insert would pass the budget the whole map is dropped (a fresh epoch), which /// only costs re-decoding, and an image larger than the whole budget is never kept. The budget /// caps memory per interpreter cache. A poisoned lock decodes without the cache. +/// +/// PdfCraft patch (91): a decode that fails is kept too, as the warnings it reported, so an +/// image drawn again and again that doesn't decode (damaged data, a JPX or JBIG2 stream its +/// decoder refuses) isn't decoded again at every draw: a later draw reports the same warnings +/// and gets nothing, as the decode did. A failure counts [`FAILED_DECODE_BYTES`] towards the +/// budget, so failures can't grow the map without bound either. A failure that may come from +/// what the page had left (its JBIG2 allowance, `ContentTruncated`) isn't kept: another page, +/// or the same page drawn again, starts with a fresh allowance. #[derive(Clone)] pub(crate) struct DecodedImageCache { inner: Arc>, @@ -108,14 +117,55 @@ pub(crate) struct DecodedImageKey { pub(crate) default_space: Option, pub(crate) target: Option<(u32, u32)>, pub(crate) preview: bool, + /// PdfCraft patch (82): the size a soft mask far larger than where it is drawn was averaged + /// down to (`ImageXObject::decoded_soft_mask`), `None` for a mask kept at its own size. + pub(crate) shrunk_to: Option<(u32, u32)>, +} + +/// PdfCraft patch (91): what the budget charges for a failed decode, about what its key and +/// entry take in the map. +const FAILED_DECODE_BYTES: usize = 128; + +/// PdfCraft patch (91): what a [`DecodedImageCache`] keeps for a key. +enum DecodedEntry { + /// The decoded image. + Decoded(Box), + /// The decode failed, reporting these warnings ([`warning_bit`] flags). + Failed(u8), +} + +/// PdfCraft patch (91): `warning` as a flag in a set of warnings. +fn warning_bit(warning: InterpreterWarning) -> u8 { + match warning { + InterpreterWarning::UnsupportedFont => 1, + InterpreterWarning::ImageDecodeFailure => 2, + InterpreterWarning::ContentTruncated => 4, + InterpreterWarning::ColorFallback => 8, + InterpreterWarning::Type3WidthsMissing => 16, + } +} + +/// PdfCraft patch (91): the warnings in a set of [`warning_bit`] flags. +fn warnings_in(bits: u8) -> impl Iterator { + [ + InterpreterWarning::UnsupportedFont, + InterpreterWarning::ImageDecodeFailure, + InterpreterWarning::ContentTruncated, + InterpreterWarning::ColorFallback, + InterpreterWarning::Type3WidthsMissing, + ] + .into_iter() + .filter(move |warning| bits & warning_bit(*warning) != 0) } #[derive(Default)] struct DecodedImageCacheInner { - map: HashMap>>, + map: HashMap, bytes: usize, /// PdfCraft patch (32): how many keyed decodes ran (cache misses). decodes: usize, + /// PdfCraft patch (82): how many of `decodes` were of masks (keys with `mask_output`). + mask_decodes: usize, } impl DecodedImageCache { @@ -132,6 +182,15 @@ impl DecodedImageCache { self.inner.lock().map(|locked| locked.decodes).unwrap_or(0) } + /// PdfCraft patch (82): how many of [`Self::decodes`] were of soft masks and `/Mask` images, + /// for tests. 0 when the lock is poisoned. + pub(crate) fn mask_decodes(&self) -> usize { + self.inner + .lock() + .map(|locked| locked.mask_decodes) + .unwrap_or(0) + } + /// The same cache, for a preview (`true`) or a final render. pub(crate) fn for_render(&self, preview: bool) -> Self { Self { @@ -140,44 +199,76 @@ impl DecodedImageCache { } } - /// The decoded image for `key`, from the cache or from `f`. `None` as the key decodes - /// without the cache. + /// The decoded image for `key`, from the cache or from `f`, which decodes reporting its + /// warnings to the sink it is handed. `None` as the key decodes without the cache. + /// PdfCraft patch (91): `sink` gets the warnings of the decode, or those of the failed + /// decode kept for `key`. pub(crate) fn get_or_insert_with( &self, key: Option, budget: usize, size_of: impl FnOnce(&T) -> usize, - f: impl FnOnce() -> Option, + sink: &WarningSinkFn, + f: impl FnOnce(&WarningSinkFn) -> Option, ) -> Option { - let Some(key) = key else { return f() }; - // Fast path: a hit clones the pixels without decoding. + let Some(key) = key else { return f(sink) }; + // Fast path: a hit clones the pixels without decoding, and a kept failure reports its + // warnings again without decoding. let hit = match self.inner.lock() { - Ok(locked) => locked - .map - .get(&key) - .and_then(|val| val.as_ref()) - .and_then(|val| val.downcast_ref::().cloned()), + Ok(locked) => match locked.map.get(&key) { + Some(DecodedEntry::Decoded(val)) => val.downcast_ref::().cloned().map(Ok), + Some(DecodedEntry::Failed(bits)) => Some(Err(*bits)), + None => None, + }, // A thread panicked while holding the lock: decode without the cache. - Err(_) => return f(), + Err(_) => return f(sink), }; - if let Some(hit) = hit { - return Some(hit); + match hit { + Some(Ok(hit)) => return Some(hit), + Some(Err(bits)) => { + warnings_in(bits).for_each(|warning| sink(warning)); + return None; + } + None => {} } - // Miss: decode without holding the lock (decoding must never deadlock the cache). - let val = f(); + // Miss: decode without holding the lock (decoding must never deadlock the cache), + // noting the warnings it reports. + let reported = Arc::new(AtomicU8::new(0)); + let recording: WarningSinkFn = { + let (reported, sink) = (reported.clone(), sink.clone()); + Arc::new(move |warning| { + reported.fetch_or(warning_bit(warning), Ordering::Relaxed); + sink(warning); + }) + }; + let val = f(&recording); + let reported = reported.load(Ordering::Relaxed); let Ok(mut locked) = self.inner.lock() else { return val; }; locked.decodes = locked.decodes.saturating_add(1); + if key.mask_output { + locked.mask_decodes = locked.mask_decodes.saturating_add(1); + } // Another thread may have decoded it meanwhile: keep the first copy. - if let Some(existing) = locked.map.get(&key) - && let Some(existing) = existing.as_ref().and_then(|val| val.downcast_ref::()) - { - return Some(existing.clone()); + match locked.map.get(&key) { + Some(DecodedEntry::Decoded(existing)) => { + if let Some(existing) = existing.downcast_ref::() { + return Some(existing.clone()); + } + } + Some(DecodedEntry::Failed(_)) if val.is_none() => return None, + _ => {} } let size = match &val { Some(v) => size_of(v), - None => 0, + // A failure the page's JBIG2 allowance may have caused isn't the image's to keep. + None if reported & warning_bit(InterpreterWarning::ContentTruncated) != 0 + || hayro_syntax::page_jbig2_work_spent() => + { + return None; + } + None => FAILED_DECODE_BYTES, }; if size > budget { return val; @@ -186,12 +277,14 @@ impl DecodedImageCache { locked.map.clear(); locked.bytes = 0; } + let entry = match &val { + Some(val) => DecodedEntry::Decoded(Box::new(val.clone())), + None => DecodedEntry::Failed(reported), + }; + // An entry replaced (a failure another thread kept) isn't given back: the budget + // errs towards a fresh epoch. locked.bytes = locked.bytes.saturating_add(size); - locked.map.insert( - key, - val.clone() - .map(|val| Box::new(val) as Box), - ); + locked.map.insert(key, entry); val } } diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index 35aebd93e0..1644e6fbec 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -5,7 +5,7 @@ use crate::font::{Font, StandardFont}; use crate::interpret::state::{ClipType, State, TextStateFont}; use crate::ocg::OcgState; use crate::util::{BezPathExt, Float64Ext}; -use crate::{ClipPath, Device, FillRule, InterpreterSettings, StrokeProps}; +use crate::{ClipPath, Device, FillRule, InterpreterSettings, InterpreterWarning, StrokeProps}; use hayro_syntax::content::ops::Transform; use hayro_syntax::object::Dict; use hayro_syntax::object::Name; @@ -92,6 +92,37 @@ pub(crate) fn decode_content<'a>(stream: &Stream<'a>) -> Option usize { + CONTENT_LEFT.with(core::cell::Cell::get) +} + +/// PdfCraft patch (87): `len` bytes of content held within the page's [`MAX_PAGE_CONTENT`] +/// until dropped, as [`DecodedContent`] holds what it decoded: content kept from an earlier +/// decode (a cached Type 3 glyph procedure) counts as it did when it was decoded. +pub(crate) struct HeldContent(usize); + +impl Drop for HeldContent { + fn drop(&mut self) { + let held = self.0; + CONTENT_LEFT.with(|c| c.set(c.get().saturating_add(held).min(MAX_PAGE_CONTENT))); + } +} + +/// PdfCraft patch (87): hold `len` bytes of content within what is left of the page's +/// [`MAX_PAGE_CONTENT`]. `None`, and the page reported truncated, when they don't fit, as +/// [`decode_content`] skips content the budget can't hold. +pub(crate) fn hold_content(len: usize) -> Option { + let left = content_left(); + if left == 0 || left < len { + CONTENT_TRUNCATED.with(|truncated| truncated.set(true)); + warn!("page content budget exceeded"); + return None; + } + charge_content(len); + Some(HeldContent(len)) +} + /// PdfCraft patch: count `len` bytes held for the rest of the page (its own contents). pub(crate) fn charge_content(len: usize) { CONTENT_LEFT.with(|c| c.set(c.get().saturating_sub(len))); @@ -129,6 +160,22 @@ pub struct InterpreterCache<'a> { /// PdfCraft patch: decoded images, shared by every render through this cache so a tile /// no longer decodes its page's images again. pub(crate) decoded_cache: DecodedImageCache, + /// PdfCraft patch (87): what the Type 3 fonts of this cache keep of their decoded glyph + /// procedures, together (see `font::type3`). + pub(crate) type3_procs: Rc, +} + +/// PdfCraft patch (87): decoded Type 3 glyph procedure bytes all the fonts of one +/// [`InterpreterCache`] keep together. Fonts live as long as the cache, so the bound is on the +/// cache, not on each font: a document can have thousands of Type 3 fonts. +pub(crate) const MAX_CACHED_TYPE3_PROCS: usize = 32 << 20; + +/// PdfCraft patch (87): the decoded glyph procedure bytes the Type 3 fonts of a cache keep, +/// and how many procedures they decoded. +#[derive(Default)] +pub(crate) struct Type3Procs { + pub(crate) held: core::cell::Cell, + pub(crate) decodes: core::cell::Cell, } impl<'a> Default for InterpreterCache<'a> { @@ -144,15 +191,29 @@ impl<'a> InterpreterCache<'a> { font_cache: Rc::new(RefCell::new(HashMap::new())), object_cache: Cache::new(), decoded_cache: DecodedImageCache::new(), + type3_procs: Rc::default(), } } + /// PdfCraft patch (87): how many Type 3 glyph procedures the fonts of this cache decoded, + /// for diagnostics and tests. A procedure kept from an earlier copy of its glyph isn't + /// decoded again. + pub fn type3_proc_decodes(&self) -> usize { + self.type3_procs.decodes.get() + } + /// PdfCraft patch (32): how many images were decoded through this cache (its misses, not /// counting images that are never cached), for diagnostics and tests. pub fn decoded_image_decodes(&self) -> usize { self.decoded_cache.decodes() } + /// PdfCraft patch (82): how many of [`Self::decoded_image_decodes`] were of soft masks and + /// `/Mask` images, for diagnostics and tests. + pub fn decoded_mask_decodes(&self) -> usize { + self.decoded_cache.mask_decodes() + } + /// PdfCraft patch (48): how many `/ICCBased` profiles were read through this cache, for /// diagnostics and tests. A profile used again, by the same stream object or by a copy with /// the same dictionary and bytes, is read once. @@ -586,6 +647,12 @@ impl<'a> Context<'a> { .clone() }; + // PdfCraft patch (90): tell the caller a Type 3 font lacks the /Widths it requires, + // so the spacing taken from its glyph procedures can be reported. + if resolved.as_ref().is_some_and(Font::type3_widths_missing) { + (self.settings.warning_sink)(InterpreterWarning::Type3WidthsMissing); + } + if let Some(resolved) = resolved { Some(TextStateFont::Font(resolved)) } else { diff --git a/vendor/hayro-interpret/src/encode.rs b/vendor/hayro-interpret/src/encode.rs index 8a76ee4177..0635e34731 100644 --- a/vendor/hayro-interpret/src/encode.rs +++ b/vendor/hayro-interpret/src/encode.rs @@ -226,7 +226,8 @@ fn encode_axial_shading( } /// PdfCraft patch: pixel visits one mesh shading may make, per pixel of the area it is drawn in -/// and per triangle. Sampling walks each triangle's bounding box (rounded out to whole pixels), +/// and per triangle. Sampling walks each triangle's bounding box (rounded out to whole pixels; +/// patch 99: only the columns of each row the triangle can reach), /// so triangles far off the page (a fuzzed patch decoded onto ±40000) asked for 65536 × 65536 /// samples, and many triangles covering the same pixels repeat the work. A triangle's box is /// about twice its area, and a sub-pixel triangle's box is at most 2 × 2 pixels. @@ -346,13 +347,43 @@ impl TriangleSampler { } self.visits_left = self.visits_left.saturating_add(MESH_VISITS_PER_TRIANGLE); + let (columns_start, columns_end) = (bbox.x0.floor() as u16, bbox.x1.ceil() as u16); for y in (bbox.y0.floor() as u16)..(bbox.y1.ceil() as u16) { - for x in (bbox.x0.floor() as u16)..(bbox.x1.ceil() as u16) { - if self.visits_left == 0 { - warn!("mesh shading visit budget exceeded; the rest is not drawn"); + // PdfCraft patch (99): only the columns where this row's sample points (the + // corner and the centre, at heights `y` and `y + 0.5`) can lie in the triangle, + // not the whole box. A thin triangle lying across the pixel grid has a box many + // times its area (the cells of a narrow curved band of patches, cut into a fixed + // 19 × 19 grid, are a few pixels long and a fraction of one wide, at every angle), + // and walking the box spent the visit budget before the last patches: painted + // through a path the size of its own box, a rainbow of tensor patches lost its + // inner bands. A box two columns wide or less is walked whole (no row of it is + // narrower by more than a column, which isn't worth finding), and a row with no + // column still costs a visit, so the walk stays within the budget however tall + // the triangle is. + let columns = if columns_end.saturating_sub(columns_start) <= 2 { + columns_start..columns_end + } else { + let top = f64::from(y); + match x_extent_between(&t, top - SPAN_SLACK, top + 0.5 + SPAN_SLACK) { + // Pixel `x` samples at `x` and `x + 0.5`; the casts saturate. + Some((lo, hi)) => { + let start = (lo - 0.5 - SPAN_SLACK).floor() as u16; + let end = ((hi + SPAN_SLACK).floor() as u16).saturating_add(1); + columns_start.max(start)..columns_end.min(end) + } + None => 0..0, + } + }; + if columns.is_empty() { + if !self.take_visit() { + return false; + } + continue; + } + for x in columns { + if !self.take_visit() { return false; } - self.visits_left -= 1; let corner = Point::new(x as f64, y as f64); let sampled = if t.contains_point(corner) { self.map.insert((x, y), t.interpolate(corner)); @@ -379,6 +410,53 @@ impl TriangleSampler { } true } + + /// Spend one visit; `false` (with a warning) when none is left. + fn take_visit(&mut self) -> bool { + if self.visits_left == 0 { + warn!("mesh shading visit budget exceeded; the rest is not drawn"); + return false; + } + self.visits_left -= 1; + true + } +} + +/// PdfCraft patch (99): how far past a row's sample points the triangle's extent is still +/// walked, in pixels. A sample a little outside the triangle can still pass `covers`, whose +/// tolerance is relative to the triangle's size (1e-9 of it, under 1e-4 pixel on a 65536-pixel +/// canvas); this is wider than that and than the rounding of the extent itself. A triangle +/// whose corners all but coincide (two of them 1e-13 pixel apart, its area 1e-14) can pass +/// `covers` for points well outside it, through rounding in its barycentric division; the +/// box walk took such points, the extent leaves them out. +const SPAN_SLACK: f64 = 1e-3; + +/// PdfCraft patch (99): the least and greatest x of the part of `t` between the heights `y0` +/// and `y1`, or `None` when no part of it is there: the ends of that part are the triangle's +/// corners inside the band and the points where its edges cross the band's two lines. +fn x_extent_between(t: &Triangle, y0: f64, y1: f64) -> Option<(f64, f64)> { + let corners = [t.p0.point, t.p1.point, t.p2.point]; + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + let mut add = |x: f64| { + lo = lo.min(x); + hi = hi.max(x); + }; + for (a, b) in corners.iter().zip(corners.iter().cycle().skip(1)) { + if (y0..=y1).contains(&a.y) { + add(a.x); + } + // A level edge lies along the band or misses it; its corners are taken above. + if a.y != b.y { + for line in [y0, y1] { + let s = (line - a.y) / (b.y - a.y); + if (0.0..=1.0).contains(&s) { + add(a.x + s * (b.x - a.x)); + } + } + } + } + // NaN corners add nothing (every comparison fails), so this is `None` for them. + (lo <= hi).then_some((lo, hi)) } /// The triangle `v0`, `v1`, `v2` with its points mapped through `transform` (its colours as diff --git a/vendor/hayro-interpret/src/font/blob.rs b/vendor/hayro-interpret/src/font/blob.rs index 0aa8da435d..184f7d5a19 100644 --- a/vendor/hayro-interpret/src/font/blob.rs +++ b/vendor/hayro-interpret/src/font/blob.rs @@ -67,18 +67,23 @@ impl CffFontBlob { let font = CffFontRef::new(data.as_ref().as_ref(), 0, None).ok()?; let cff = Cff::read(ReadFontData::new(data.as_ref().as_ref())).ok()?; let charset = font.charset(); + // PdfCraft patch (93): a font dict that cannot be read (a CID font's FDArray entry + // emptied by a subsetter, with no Private DICT) leaves only the glyphs it selects + // missing. The font is dropped only when no font dict is usable. let subfonts = (0..font.num_subfonts()) .map(|index| font.subfont(index, &[]).ok()) - .collect::>>()?; + .collect::>(); + if !subfonts.iter().any(Option::is_some) { + return None; + } - let yoke = Yoke::, FontData>::attach_to_cart(data.clone(), |data| { + // PdfCraft patch (93): the subfonts read above own their data, so they move into the + // yoke instead of being read again. + let yoke = Yoke::, FontData>::attach_to_cart(data.clone(), move |data| { let bytes = data.as_ref(); let font = CffFontRef::new(bytes, 0, None).unwrap(); let cff = Cff::read(ReadFontData::new(bytes)).unwrap(); let charset = font.charset(); - let subfonts = (0..font.num_subfonts()) - .map(|index| font.subfont(index, &[]).unwrap()) - .collect(); CFFYoke { font, cff, @@ -87,7 +92,7 @@ impl CffFontBlob { } }); - let _ = (cff, charset, subfonts); + let _ = (cff, charset); Some(Self(Arc::new(yoke), Arc::new(OnceLock::new()))) } @@ -105,7 +110,7 @@ impl CffFontBlob { fn subfont(&self, glyph: GlyphId) -> Option<&Subfont> { let index = self.font().subfont_index(glyph)? as usize; - self.0.as_ref().get().subfonts.get(index) + self.0.as_ref().get().subfonts.get(index)?.as_ref() } pub(crate) fn outline_glyph(&self, glyph: GlyphId) -> BezPath { @@ -195,8 +200,48 @@ impl Debug for OpenTypeFontBlob { } } +/// PdfCraft patch (83): a copy of the font with its `head.indexToLocFormat` set to the format its +/// `loca` table is in, when the field holds neither 0 (short offsets) nor 1 (long), or `None` when +/// it needs no change. The glyph outline reader takes only 1 as long, so a font whose field reads +/// 256 (the value's bytes swapped) had its long `loca` read as short offsets: every glyph came +/// out wrong, and a line of text set in it drew boxes (pdf.js `issue2537r.pdf`). The `loca` +/// table's length decides: room for `numGlyphs + 1` offsets of 4 bytes is long, room for only +/// that many of 2 bytes short (a table may carry padding after its offsets), and a table too +/// short for either is left as it is. +fn with_loca_format_fixed(data: &[u8], index: u32) -> Option> { + use skrifa::raw::types::Tag; + const HEAD: Tag = Tag::new(b"head"); + const LOCA: Tag = Tag::new(b"loca"); + // The `indexToLocFormat` field's offset in `head`. + const FORMAT_AT: usize = 50; + let f = FontRef::from_index(data, index).ok()?; + let format = f.head().ok()?.index_to_loc_format(); + if format == 0 || format == 1 { + return None; + } + let offsets = usize::from(f.maxp().ok()?.num_glyphs()).checked_add(1)?; + let loca_len = f.table_data(LOCA)?.len(); + let long = if loca_len >= offsets.checked_mul(4)? { + true + } else if loca_len >= offsets.checked_mul(2)? { + false + } else { + return None; + }; + let head_at = f.table_directory.table_records().iter().find(|r| r.tag() == HEAD)?.offset() as usize; + let at = head_at.checked_add(FORMAT_AT)?; + let mut fixed = data.to_vec(); + fixed.get_mut(at..at.checked_add(2)?)?.copy_from_slice(&i16::from(long).to_be_bytes()); + Some(fixed) +} + impl OpenTypeFontBlob { pub(crate) fn new(data: FontData, index: u32) -> Option { + // PdfCraft patch (83): see `with_loca_format_fixed`. + let data = match with_loca_format_fixed(data.as_ref().as_ref(), index) { + Some(fixed) => Arc::new(fixed) as FontData, + None => data, + }; // Check first whether the font is valid so we can unwrap in the closure. let f = FontRef::from_index(data.as_ref().as_ref(), index).ok()?; // Reject fonts with invalid post table version, fixes pdf.js issue 9462. Not sure if there @@ -377,7 +422,8 @@ struct CFFYoke<'a> { font: CffFontRef<'a>, cff: Cff<'a>, charset: Option>, - subfonts: Vec, + /// PdfCraft patch (93): `None` for a font dict that could not be read. + subfonts: Vec>, } #[cfg(test)] diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index 6c4ea2bc9c..8115cb9e47 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -16,10 +16,12 @@ use kurbo::{BezPath, Vec2}; use skrifa::attribute::Style; use skrifa::charmap::MapVariant; use skrifa::raw::collections::int_set::Domain; +use skrifa::raw::tables::post::DEFAULT_GLYPH_NAMES; use skrifa::{FontRef, GlyphId, MetadataProvider}; use std::collections::HashMap; use std::ops::Deref; use std::sync::Arc; +use std::sync::OnceLock; #[derive(Debug)] pub(crate) struct Type0Font { @@ -42,6 +44,9 @@ pub(crate) struct Type0Font { /// Whether the `to_unicode` map is a UCS2 `CMap` (CID-indexed) rather than /// a `ToUnicode` `CMap` (code-indexed). to_unicode_is_cid_indexed: bool, + /// PdfCraft patch (84): the character collection's UCS2 CMap (by CID), for the codes the + /// font's own `/ToUnicode` gives nothing for, whether or not its program is embedded. + ucs2: Option, /// PdfCraft patch: whether the CIDs belong to an Adobe character collection (Japan1, GB1, /// CNS1, Korea1), whose glyph numbering no substitute other than a CID-keyed CFF font shares. adobe_collection: bool, @@ -50,6 +55,11 @@ pub(crate) struct Type0Font { /// such text as the code's Unicode value; the identity CMap the name resolves to maps codes /// to CIDs only and gave no text at all. identity_to_unicode: bool, + /// PdfCraft patch: an Arial or a Times New Roman the PDF doesn't embed, as a TrueType CID + /// font (`CIDFontType2`) whose CIDs aren't of an Adobe character collection. Its CIDToGIDMap + /// gives glyph indexes in the author's font, which numbers its first 258 glyphs in the + /// standard Macintosh order of the TrueType `post` table (#5889, #6449). + mac_glyph_order: bool, } impl Type0Font { @@ -131,18 +141,24 @@ impl Type0Font { .is_some_and(|n| matches!(n.as_ref(), b"Identity-H" | b"Identity-V")); let mut to_unicode_is_cid_indexed = false; + // PdfCraft patch (84): the character collection's UCS2 CMap (ISO 32000-2 §9.10.2), CID + // to Unicode, for a font of an Adobe collection whether or not its program is embedded. + let mut ucs2 = character_collection + .as_ref() + .and_then(|cc| cc.family.ucs2_cmap()) + .and_then(|name| (cmap_resolver)(name)) + .map(|data| Ucs2 { + data, + resolver: cmap_resolver.clone(), + map: OnceLock::new(), + }); // If there is no ToUnicode map, try to get the UCS2 CMap. if fallback && to_unicode.is_none() - && let Some(cc) = character_collection.as_ref() - && let Some(ucs2_name) = cc.family.ucs2_cmap() - && let Some(data) = (cmap_resolver)(ucs2_name) + && let Some(ucs2_cmap) = ucs2.take().and_then(|u| u.parse()) { - let resolver = cmap_resolver.clone(); - if let Some(ucs2_cmap) = CMap::parse(data, move |n| (resolver)(n)) { - to_unicode = Some(ucs2_cmap); - to_unicode_is_cid_indexed = true; - } + to_unicode = Some(ucs2_cmap); + to_unicode_is_cid_indexed = true; } let adobe_collection = character_collection @@ -153,6 +169,17 @@ impl Type0Font { .get::>(BASE_FONT) .map(|n| strip_subset_prefix(n.as_str()).to_string()); + // PdfCraft patch: the descendant names the font program itself; a Type 0 font's own + // `/BaseFont` may carry the CMap's name after it. + let mac_glyph_order = fallback + && !adobe_collection + && descendant_font + .get::>(SUBTYPE) + .is_some_and(|s| s.deref() == CID_FONT_TYPE2) + && descendant_font + .get::>(BASE_FONT) + .is_some_and(|n| numbers_glyphs_in_mac_order(strip_subset_prefix(n.as_str()))); + // Extract font flags from descriptor let font_flags = font_descriptor .get::(FLAGS) @@ -173,8 +200,10 @@ impl Type0Font { font_flags, fallback, to_unicode_is_cid_indexed, + ucs2, adobe_collection, identity_to_unicode, + mac_glyph_order, }) } @@ -198,6 +227,16 @@ impl Type0Font { return glyph; } + // PdfCraft patch: the CID of an Arial or a Times New Roman the PDF doesn't embed names + // one of that font's glyphs, which the substitute draws by that name. Taken as the + // substitute's own glyph index it drew an unrelated glyph: "Low Power" as + // "@ck 'Dck Yf" (#5889), "Salarié(e) du régime" as "Salari²*g+f w't²i lo" (#6449). + if self.mac_glyph_order + && let Some(glyph) = self.map_via_mac_glyph_name(cid) + { + return glyph; + } + // PdfCraft patch: a substitute that isn't CID-keyed numbers its glyphs its own way, so // the CID of an Adobe character collection taken as its glyph index draws an unrelated // glyph. Draw .notdef, as for a CID the font has no glyph for. @@ -307,6 +346,27 @@ impl Type0Font { } } + /// PdfCraft patch: the substitute's glyph for the author's glyph that `cid` gives, found by + /// that glyph's name in the standard Macintosh order. `None` past the 258 glyphs the order + /// names, and for a substitute that isn't a name-keyed CFF font (the standard substitutes + /// are); .notdef for a name the substitute has no glyph for. + fn map_via_mac_glyph_name(&self, cid: u32) -> Option { + let FontType::Cff(c) = &self.font_type else { + return None; + }; + if c.is_cid() { + return None; + } + let gid = self.cid_to_gid_map.map(u16::try_from(cid).ok()?); + let name = *DEFAULT_GLYPH_NAMES.get(usize::try_from(gid.to_u32()).ok()?)?; + // `.null` and `nonmarkingreturn` draw nothing, and `nonbreakingspace` is a space. + let name = match name { + ".null" | "nonmarkingreturn" | "nonbreakingspace" => "space", + name => name, + }; + Some(c.glyph_index_by_name(name).unwrap_or(GlyphId::NOTDEF)) + } + fn code_to_cid(&self, code: u32) -> Option { for byte_len in 1..=4_u8 { if let Some(cid) = self.encoding.lookup_cid_code(code, byte_len) { @@ -415,6 +475,13 @@ impl Type0Font { } } + /// PdfCraft patch: the code's width w0 (its horizontal displacement, from `/W` or `/DW`) + /// in either writing mode. In vertical writing it is the glyph's extent across the column + /// (ISO 32000-2 §9.7.4.3). + pub(crate) fn code_horizontal_width(&self, code: u32) -> f32 { + self.horizontal_width(self.code_to_cid(code).unwrap_or(0)) + } + fn horizontal_width(&self, cid: u32) -> f32 { self.widths.get(&cid).copied().unwrap_or(self.dw) } @@ -466,13 +533,63 @@ impl Type0Font { } else { code }; - return to_unicode.lookup_bf_string(key); + // PdfCraft patch (85): a `/ToUnicode` written as a CID CMap (`begincidrange` / + // `begincidchar`, whose destinations are numbers) gives the code's Unicode value as + // that number, as glyph lookup already reads it (`map_via_unicode`) and as pdftotext + // and PDFium extract it. ISO 32000-2 §9.10.3 has `beginbfrange` / `beginbfchar` here; + // Word writes CID ranges, and such text extracted nothing. + if let Some(s) = to_unicode.lookup_bf_string(key).or_else(|| { + (1..=4) + .find_map(|len| { + to_unicode + .lookup_cid_code(key, len) + .and_then(|cid| char::from_u32(cid.to_u32())) + }) + .map(BfString::Char) + }) { + return Some(s); + } + } + // PdfCraft patch (84): then the collection's UCS2 CMap, by the code's CID. + if let Some(ucs2) = self.ucs2.as_ref().and_then(Ucs2::cmap) + && let Some(s) = self + .code_to_cid(code) + .and_then(|cid| ucs2.lookup_bf_string(cid)) + { + return Some(s); } None } } +/// PdfCraft patch (84): a collection's UCS2 CMap, parsed the first time a code needs it: a font +/// whose `/ToUnicode` maps every code it shows never parses it. +struct Ucs2 { + data: &'static [u8], + resolver: CMapResolverFn, + map: OnceLock>, +} + +impl Ucs2 { + fn parse(&self) -> Option { + let resolver = self.resolver.clone(); + CMap::parse(self.data, move |n| (resolver)(n)) + } + + fn cmap(&self) -> Option<&CMap> { + self.map.get_or_init(|| self.parse()).as_ref() + } +} + +impl std::fmt::Debug for Ucs2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Ucs2") + .field("bytes", &self.data.len()) + .finish_non_exhaustive() + } +} + impl CacheKey for Type0Font { fn cache_key(&self) -> u128 { self.cache_key @@ -582,6 +699,37 @@ fn is_variation_selector(c: char) -> bool { matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}') } +/// PdfCraft patch: whether `name`, a `/BaseFont` without its subset tag, is one of the four +/// faces of Arial or Times New Roman as exporters name them: `ArialMT`, `Arial-BoldMT`, +/// `Arial-ItalicMT`, `Arial-BoldItalicMT`, `TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT` and so +/// on, or `Arial` or `TimesNewRoman` followed by `,Bold`, `,Italic` or `,BoldItalic`. Both +/// number their first 258 glyphs in the standard Macintosh order. Arial Narrow, Arial Black, +/// Arial Unicode MS and Arial Rounded are other fonts, numbered their own way. Letter case +/// doesn't matter, `MT` included. +fn numbers_glyphs_in_mac_order(name: &str) -> bool { + let (family, style) = name.split_once([',', '-', ' ']).unwrap_or((name, "")); + let style: String = style + .chars() + .filter(|c| !matches!(c, ',' | '-' | ' ')) + .collect(); + ["Arial", "TimesNewRoman", "TimesNewRomanPS"] + .iter() + .any(|f| without_mt(family).eq_ignore_ascii_case(f)) + && ["", "Regular", "Bold", "Italic", "BoldItalic"] + .iter() + .any(|s| without_mt(&style).eq_ignore_ascii_case(s)) +} + +/// PdfCraft patch: `name` without a trailing `MT` in any letter case (Monotype's mark in +/// PostScript names such as `ArialMT`). +fn without_mt(name: &str) -> &str { + name.len() + .checked_sub(2) + .and_then(|stem| Some((name.get(..stem)?, name.get(stem..)?))) + .filter(|(_, suffix)| suffix.eq_ignore_ascii_case("MT")) + .map_or(name, |(stem, _)| stem) +} + /// PdfCraft patch: the largest CID (ISO 32000-2 §9.7.2; CIDs are at most two bytes). const MAX_CID: u32 = 65_535; diff --git a/vendor/hayro-interpret/src/font/mod.rs b/vendor/hayro-interpret/src/font/mod.rs index e0fc75981b..77078bf5d3 100644 --- a/vendor/hayro-interpret/src/font/mod.rs +++ b/vendor/hayro-interpret/src/font/mod.rs @@ -87,6 +87,8 @@ impl Glyph<'_> { /// 1. `ToUnicode` cmap /// 2. Glyph name → Unicode (via Adobe Glyph List) /// 3. Unicode naming conventions (e.g., "uni0041", "u0041") + /// 4. PdfCraft patch (100), Type1 and CFF fonts only: a name that only numbers its glyph, as + /// for Type3 fonts below /// /// **For CID Fonts (Type0):** /// 1. `ToUnicode` cmap @@ -94,6 +96,12 @@ impl Glyph<'_> { /// /// **For Type3 Fonts:** /// 1. `ToUnicode` cmap + /// 2. PdfCraft patch: the `/Encoding` glyph name → Unicode (an Annex D.2 alias code reads as + /// the ordinary character) + /// 3. PdfCraft patch: a printable ASCII code (32–126) as itself + /// 4. PdfCraft patch (100): above 126, a name that only numbers its glyph (`C174`) as the + /// code's Latin-1 character, when the number is that code and every numbered name in the + /// font names its own code /// /// Returns `None` if the Unicode value could not be determined. /// @@ -179,6 +187,19 @@ impl OutlineGlyph { self.font.glyph_advance_width(self.char_code) } + /// PdfCraft patch: for a glyph of a vertical-writing Type0 font, its width w0 (the + /// horizontal displacement from `/W` or `/DW`), which is how wide the glyph sits across the + /// column (ISO 32000-2 §9.7.4.3). `None` for horizontal writing, where + /// [`advance_width`](Self::advance_width) gives it. + pub fn vertical_glyph_width(&self) -> Option { + match &self.font { + OutlineFont::Type0(font) if !font.is_horizontal() => { + Some(font.code_horizontal_width(self.char_code)) + } + _ => None, + } + } + /// Get the cache key for this glyph's font. /// /// This identifies the font uniquely, even when `font_data()` returns `None` @@ -216,9 +237,11 @@ impl<'a> Type3Glyph<'a> { .render_glyph(self, transform, glyph_transform, paint, device); } - /// Returns the Unicode code point for this glyph, if available. + /// Returns the Unicode code point for this glyph, if available: the `ToUnicode` cmap, then + /// the `/Encoding` glyph name, a printable ASCII code, and a numbered glyph name at its own + /// code (PdfCraft patches). /// - /// Note: Type3 fonts can only provide Unicode via `ToUnicode` cmap. + /// See [`Glyph::as_unicode`] for details on the fallback chain used. pub fn as_unicode(&self) -> Option { self.font.char_code_to_unicode(self.char_code) } @@ -244,8 +267,14 @@ impl<'a> Font<'a> { font_resolver: &FontResolverFn, cmap_resolver: &CMapResolverFn, ) -> Option { - let f_type = match dict.get::>(SUBTYPE)?.deref() { - TYPE1 | MM_TYPE1 => { + // PdfCraft patch (94): a font dictionary whose `/Subtype` is missing or the empty name + // (`/Subtype /`) is read as a Type 1 font, so its `/BaseFont` still picks the font: a + // `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). `/Subtype` is + // required (ISO 32000-2 §9.6.2.1, Table 109); other readers fall back to Type 1 this way. + let subtype = dict.get::>(SUBTYPE); + let subtype: &[u8] = subtype.as_deref().unwrap_or(b""); + let f_type = match subtype { + b"" | TYPE1 | MM_TYPE1 => { FontType::Type1(Rc::new(Type1Font::new(dict, font_resolver, cmap_resolver)?)) } // PDFBOX-5463: PDF viewers seem to accept OpenType as well. @@ -271,6 +300,12 @@ impl<'a> Font<'a> { Some(Self(cache_key, f_type)) } + /// PdfCraft patch (90): whether this is a Type 3 font without `/Widths` (or + /// `/FirstChar`/`/LastChar`), whose glyphs advance by their `d0`/`d1` widths. + pub(crate) fn type3_widths_missing(&self) -> bool { + matches!(&self.1, FontType::Type3(t) if t.widths_missing()) + } + pub(crate) fn new_standard( standard_font: StandardFont, font_resolver: &FontResolverFn, @@ -669,7 +704,7 @@ pub(crate) fn glyph_name_to_unicode(name: &str) -> Option { return unicode_str.chars().next(); } - unicode_from_name(name).or_else(|| { + agl_unicode_from_name(name).or_else(|| { warn!("failed to map glyph name {} to unicode", name); None @@ -719,6 +754,99 @@ pub(crate) fn glyph_name_to_text(name: &str) -> Option { } } +/// PdfCraft patch: the numbers a glyph name that only numbers its glyph gives (`C39`, `G27`, +/// `MT174`: one to three ASCII letters, then one to six decimal or hexadecimal digits), for +/// every split into letters and digits, read in decimal and in hexadecimal (`GA9` is `G` with +/// hex `A9`); nothing for any other name. Everything from the first `.` is dropped first, as +/// `glyph_name_to_text` drops it. +fn glyph_name_numbers(name: &str) -> impl Iterator + '_ { + let base = name.split('.').next().unwrap_or_default(); + (1..=3).flat_map(move |prefix| { + // `from_str_radix` would also take a leading `+`: the number is digits only, and at + // most six of them (a code is under 256, leading zeros as in `C0032`). + let digits = base + .get(..prefix) + .zip(base.get(prefix..)) + .and_then(|(letters, digits)| { + (letters.bytes().all(|b| b.is_ascii_alphabetic()) + && (1..=6).contains(&digits.len()) + && digits.bytes().all(|b| b.is_ascii_hexdigit())) + .then_some(digits) + }); + digits.into_iter().flat_map(|d| { + [10, 16] + .into_iter() + .filter_map(move |radix| u32::from_str_radix(d, radix).ok()) + }) + }) +} + +/// PdfCraft patch: whether every glyph name in a simple font's encoding that only numbers its +/// glyph (see `glyph_name_numbers`) names the very code it sits at, in decimal or hexadecimal. +/// Names the glyph list or its `uni`/`u` forms read are not numbered names. A font whose names +/// number something else (`/Differences [1 /C70 /C105 …]`, a glyph's order in the subset) +/// fails, and so does one where a single name numbers another code: there a name that happens +/// to match its code (`/MT40` at 0x40, `/C1` at 233 beside `/C70` at 70, review on #3239) is a +/// coincidence, not evidence. A subset that counts its glyphs in order of first use from code 1 +/// (`/Differences [1 /C1 /C2 … /C80]`) names its own codes too, but the counter says nothing +/// about the characters (`/C65` is the 65th glyph used, not `A`): a numbered name at a C0 +/// control code (below 32), which no text shows, gives such a counter away and fails the font. +pub(crate) fn numbered_names_name_their_codes<'a>( + names: impl IntoIterator, +) -> bool { + names + .into_iter() + .filter(|(_, name)| { + glyph_names::get(name).is_none() + && unicode_from_name(name).is_none() + && glyph_name_to_text(name).is_none() + }) + .all(|(code, name)| { + let mut numbers = glyph_name_numbers(name).peekable(); + numbers.peek().is_none() || (code >= 0x20 && numbers.any(|n| n == u32::from(code))) + }) +} + +/// PdfCraft patch: the text of a glyph name outside the glyph list that only numbers its glyph +/// (see `glyph_name_numbers`) when that number is the very code the glyph is shown with: the +/// code's Latin-1 character, for codes above 127 too (`C174` reads `®`), as pdftotext, PDFium +/// and MuPDF read it. The name and the code then agree, so nothing is guessed from an +/// encoding table. A number naming another code (`/C1` at 233, review on #3239) and a control +/// character read as nothing. Callers apply it only in a font where +/// `numbered_names_name_their_codes` holds. +pub(crate) fn numbered_glyph_name_at_its_code(name: &str, code: u8) -> Option { + let c = char::from(code); + if c.is_control() { + return None; + } + glyph_name_numbers(name) + .any(|n| n == u32::from(code)) + .then_some(BfString::Char(c)) +} + +/// PdfCraft patch (92): the character a `uniXXXX` or `uXXXX` to `uXXXXXX` glyph name stands +/// for, by the Adobe Glyph List Specification: `uni` and exactly four uppercase hex digits, or +/// `u` and four to six; a value in the surrogate range or past U+10FFFF is no character. The +/// text of a glyph goes by this rule, so `uni65`, `uni20ac` and `u41` read as nothing, as they +/// do in [`glyph_name_to_text`]. +pub(crate) fn agl_unicode_from_name(name: &str) -> Option { + let upper_hex = |d: &str| d.bytes().all(|b| matches!(b, b'0'..=b'9' | b'A'..=b'F')); + let digits = name + .strip_prefix("uni") + .filter(|d| d.len() == 4) + .or_else(|| { + name.strip_prefix('u') + .filter(|d| (4..=6).contains(&d.len())) + }) + .filter(|d| upper_hex(d))?; + u32::from_str_radix(digits, 16) + .ok() + .and_then(char::from_u32) +} + +/// Upstream's lenient reading of a `uni…` or `u…` name (any number of hex digits, either case), +/// kept for finding a glyph in a font: PdfCraft patch (92) reads text by +/// [`agl_unicode_from_name`] instead. pub(crate) fn unicode_from_name(name: &str) -> Option { let convert = |input: &str| u32::from_str_radix(input, 16).ok().and_then(char::from_u32); diff --git a/vendor/hayro-interpret/src/font/standard_font.rs b/vendor/hayro-interpret/src/font/standard_font.rs index 7eb25ff2c7..b253d8c5ea 100644 --- a/vendor/hayro-interpret/src/font/standard_font.rs +++ b/vendor/hayro-interpret/src/font/standard_font.rs @@ -4,7 +4,7 @@ use crate::font::generated::{glyph_names, metrics, standard, symbol, zapf_dings} use crate::font::true_type::{Width, read_encoding, read_widths}; use crate::font::{ Encoding, FontData, FontQuery, glyph_name_to_unicode, normalized_glyph_name, stretch_glyph, - strip_subset_prefix, + strip_subset_prefix, unicode_from_name, }; use hayro_syntax::object::Dict; use hayro_syntax::object::Name; @@ -201,6 +201,32 @@ impl StandardFont { } } +/// PdfCraft patch (98): the style names that may follow `Symbol` or `SymbolMT` after a `,` or +/// `-` and still name the Symbol font (any case). +const SYMBOL_STYLES: [&str; 9] = [ + "Bold", + "Italic", + "BoldItalic", + "Oblique", + "BoldOblique", + "Regular", + "Roman", + "Normal", + "Plain", +]; + +/// PdfCraft patch (98): whether `name` (without a subset prefix) is `Symbol` or `SymbolMT`, alone +/// or followed by `,` or `-` and a style from [`SYMBOL_STYLES`]. Another name after the +/// separator (`Symbol-Helvetica`) is a different font. +fn is_symbol_name(name: &str) -> bool { + let (family, style) = match name.split_once([',', '-']) { + Some((family, style)) => (family, Some(style)), + None => (name, None), + }; + matches!(family, "Symbol" | "SymbolMT") + && style.is_none_or(|style| SYMBOL_STYLES.iter().any(|s| s.eq_ignore_ascii_case(style))) +} + enum StandardFontFamily { Helvetica, Courier, @@ -233,6 +259,13 @@ pub(crate) fn select_standard_font( _ => {} } + // PdfCraft patch (98): `SymbolMT` (the name Symbol has as a TrueType font) and the style + // variants `Symbol,Bold`, `SymbolMT,Italic` or `Symbol-Bold` are the Symbol font, drawn with + // its built-in encoding like `Symbol` itself, whatever `/Encoding` and flags they declare. + if is_symbol_name(name) { + return Some((StandardFont::Symbol, true)); + } + // Now, we bruteforce, trying to determine a suitable font based on the // keywords that appear in the name. let lower = name.to_ascii_lowercase(); @@ -769,7 +802,11 @@ fn accented_parts(name: &str) -> Option<(&'static str, &'static str, Place)> { .map(|&(_, base, accent, place)| (base, accent, place)) }; by_name(name).or_else(|| { - let c = glyph_name_to_unicode(name)?; + // Drawing keeps upstream's lenient reading of `uni` names; text goes by the glyph list + // specification (PdfCraft patch 92). + let c = glyph_names::get(name) + .and_then(|s| s.chars().next()) + .or_else(|| unicode_from_name(name))?; match COMMA_BELOW_BY_CHAR.iter().find(|(letter, _)| *letter == c) { Some((_, comma_below)) => by_name(comma_below), None => by_name(glyph_names::get_reverse(c)?), diff --git a/vendor/hayro-interpret/src/font/true_type.rs b/vendor/hayro-interpret/src/font/true_type.rs index 25b3ebc4e7..336dfc169e 100644 --- a/vendor/hayro-interpret/src/font/true_type.rs +++ b/vendor/hayro-interpret/src/font/true_type.rs @@ -18,6 +18,7 @@ use kurbo::BezPath; use skrifa::attribute::Style; use skrifa::raw::TableProvider; use skrifa::raw::tables::cmap::PlatformId; +use skrifa::raw::tables::post::DEFAULT_GLYPH_NAMES; use skrifa::{GlyphId, MetadataProvider}; use std::cell::RefCell; use std::collections::HashMap; @@ -220,6 +221,9 @@ struct EmbeddedKind { cached_mappings: RefCell>, /// PostScript name from the PDF. postscript_name: Option, + /// PdfCraft patch (101): whether the font may take the Mac OS Roman reading at all, worked out + /// once when it is loaded (`EmbeddedKind::mac_roman_name`). + mac_roman_eligible: bool, } impl EmbeddedKind { @@ -247,6 +251,27 @@ impl EmbeddedKind { .get::>(BASE_FONT) .map(|n| strip_subset_prefix(n.as_str()).to_string()); + // PdfCraft patch (101): the font names no base encoding, not even one this reader doesn't + // know (which `read_encoding` reads as the built-in one), and isn't Symbol or ZapfDingbats, + // whose built-in encodings aren't Latin (ISO 32000-2 §9.6.6.1). Both as the editor's + // `unicode_map` decides them, Symbol and ZapfDingbats recognised by name as there. + let names_base_encoding = dict.get::>(ENCODING).is_some() + || dict + .get::>(ENCODING) + .is_some_and(|d| d.get::>(BASE_ENCODING).is_some()); + let symbolic_builtin = dict.get::>(BASE_FONT).is_some_and(|n| { + let name = n.as_str(); + let flat: String = name + .rsplit('+') + .next() + .unwrap_or(name) + .chars() + .filter(char::is_ascii_alphanumeric) + .map(|c| c.to_ascii_lowercase()) + .collect(); + flat.contains("symbol") || flat.contains("zapf") || flat.contains("dingbat") + }); + Some(Self { base_font, differences, @@ -258,6 +283,7 @@ impl EmbeddedKind { encoding, cached_mappings: RefCell::new(HashMap::new()), postscript_name, + mac_roman_eligible: !names_base_encoding && !symbolic_builtin, }) } @@ -291,6 +317,8 @@ impl EmbeddedKind { // `Encoding::text_alias`. .or_else(|| self.encoding.text_alias(code)) .or_else(|| self.encoding.map_code(code)) + // PdfCraft patch (101): see `EmbeddedKind::mac_roman_name`. + .or_else(|| self.mac_roman_name(code)) // The last resort reads the code as WinAnsi whatever the font said, so it takes the // same aliasing. .or_else(|| Encoding::WinAnsi.text_alias(code)) @@ -400,6 +428,68 @@ impl EmbeddedKind { glyph } + /// PdfCraft patch (101): the Mac OS Roman glyph name a code of a symbolic font without an + /// encoding or glyph names reads as, when two things say the code is Mac OS Roman: + /// + /// - its Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws. ISO + /// 32000-2 §9.6.5.4 has single bytes "look up the associated glyph descriptions" there, and + /// has a (3, 0) subtable map "the single-byte codes in the (1, 0) subtable" prepended with + /// 0xF0; + /// - the glyph sits where the standard Macintosh glyph order (the 258 names of a version 1.0 + /// `post` table) puts that character. Fonts distilled from Macintosh originals keep that + /// order and drop the names (`post` version 3.0): pdf.js's issue2017r.pdf draws code 0x88 + /// with glyph 106, `agrave`, Mac OS Roman 0x88. + /// + /// The second test is needed: Windows subsets in the same order carry a (1, 0) subtable too, + /// but their codes are WinAnsi (code 0x92 draws glyph 183, `quoteright`, not Mac OS Roman's + /// `iacute`), and reading every such code as Mac OS Roman turned "USDA’s" into "USDAís". + /// A font that names its glyphs in its `post` table needn't keep the order (a Wingdings subset + /// draws code 0x83 with glyph 101, `Eacute` in the order, which it names `threesans`). + /// `None` when the font is non-symbolic, names a base encoding (one this reader doesn't know + /// too), is Symbol or ZapfDingbats (whose + /// built-in encodings aren't Latin, ISO 32000-2 §9.6.6.1; recognised by name as the editor's + /// `unicode_map` recognises them), is a CFF program, names its glyphs, or either test fails + /// (SAS's AMT fonts carry a wrong (1, 0) table, #4156): the code then reads as before. Glyph + /// selection is unchanged. + fn mac_roman_name(&self, code: u8) -> Option<&'static str> { + if self.is_non_symbolic() + || !self.mac_roman_eligible + || !matches!(self.encoding, Encoding::BuiltIn) + || self.cff_blob.is_some() + || !self.glyph_names.is_empty() + { + return None; + } + let glyph = self.map_code(code); + if glyph == GlyphId::NOTDEF { + return None; + } + let cmap = self.base_font.font_ref().cmap().ok()?; + let mac = cmap.encoding_records().iter().find_map(|record| { + if record.platform_id() == PlatformId::Macintosh && record.encoding_id() == 0 { + record + .subtable(cmap.offset_data()) + .ok()? + .map_codepoint(code) + } else { + None + } + })?; + if mac != glyph { + return None; + } + let standard = match *DEFAULT_GLYPH_NAMES.get(usize::try_from(glyph.to_u32()).ok()?)? { + // The order's name for Mac OS Roman 0xCA; the tables here call it `nbspace`. + "nonbreakingspace" => "nbspace", + name => name, + }; + let name = mac_os_roman::get(code).or_else(|| mac_roman::get(code))?; + if name != standard { + return None; + } + Encoding::MacRoman.text_alias(code).or(Some(name)) + } + fn glyph_width(&self, code: u8) -> f32 { match self.widths.get(code as usize).copied() { Some(Width::Value(w)) => w, diff --git a/vendor/hayro-interpret/src/font/type1.rs b/vendor/hayro-interpret/src/font/type1.rs index cad88a5850..366dd45311 100644 --- a/vendor/hayro-interpret/src/font/type1.rs +++ b/vendor/hayro-interpret/src/font/type1.rs @@ -3,7 +3,7 @@ use crate::font::standard_font::{StandardFont, StandardKind, select_standard_fon use crate::font::true_type::{Width, read_encoding, read_widths}; use crate::font::{ Encoding, FallbackFontQuery, glyph_name_to_text, glyph_name_to_unicode, normalized_glyph_name, - read_to_unicode, + numbered_glyph_name_at_its_code, numbered_names_name_their_codes, read_to_unicode, }; use crate::{CMapResolverFn, CacheKey, FontResolverFn}; use hayro_cmap::{BfString, CMap}; @@ -13,10 +13,17 @@ use hayro_syntax::object::dict::keys::{FONT_DESC, FONT_FILE, FONT_FILE3}; use kurbo::BezPath; use skrifa::GlyphId; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; #[derive(Debug)] -pub(crate) struct Type1Font(u128, Kind, Option); +pub(crate) struct Type1Font( + u128, + Kind, + Option, + /// PdfCraft patch: whether the font's numbered glyph names all name their own codes + /// (`numbered_names_name_their_codes`), worked out on first use. + OnceLock, +); impl Type1Font { pub(crate) fn new( @@ -50,23 +57,29 @@ impl Type1Font { resolver, )?), to_unicode.clone(), + OnceLock::new(), )) }; let inner = if is_cff(dict) { if let Some(cff) = CffKind::new(dict) { - Self(cache_key, Kind::Cff(cff), to_unicode) + Self(cache_key, Kind::Cff(cff), to_unicode, OnceLock::new()) } else { return fallback(); } } else if is_type1(dict) { if let Some(f) = Type1Kind::new(dict) { - Self(cache_key, Kind::Type1(f), to_unicode) + Self(cache_key, Kind::Type1(f), to_unicode, OnceLock::new()) } else { return fallback(); } } else if let Some(standard) = StandardKind::new(dict, resolver) { - Self(cache_key, Kind::Standard(standard), to_unicode) + Self( + cache_key, + Kind::Standard(standard), + to_unicode, + OnceLock::new(), + ) } else { return fallback(); }; @@ -78,7 +91,7 @@ impl Type1Font { let dict = Dict::default(); let standard = StandardKind::new_with_standard(&dict, font, true, resolver)?; - Some(Self(0, Kind::Standard(standard), None)) + Some(Self(0, Kind::Standard(standard), None, OnceLock::new())) } pub(crate) fn map_code(&self, code: u8) -> GlyphId { @@ -128,15 +141,30 @@ impl Type1Font { } // PdfCraft patch (#3169, #3170): a name outside the glyph list still reads by the // glyph list specification's full mapping (`f_i`, `hyphen.case`). A name that only - // numbers the glyph (`C39`, `c251`) says nothing about the character and reads as - // nothing: the code's StandardEncoding character would be a guess, wrong wherever the - // font's /Encoding puts the glyph elsewhere (review on #3239). - let name = match &self.1 { + // numbers the glyph (`C39`, `c251`) reads as the code's Latin-1 character only when its + // number is that code (`C39` at 39) and every numbered name in the font names its own + // code, as pdftotext, PDFium and MuPDF read it. Anywhere else the number is no + // evidence and reads as nothing: the code's StandardEncoding character would be a + // guess, wrong wherever the font's /Encoding puts the glyph elsewhere (review on #3239). + let name = self.code_to_ps_name(code)?; + glyph_name_to_text(name).or_else(|| { + let own_codes = *self.3.get_or_init(|| { + numbered_names_name_their_codes( + (0..=u8::MAX).filter_map(|c| Some((c, self.code_to_ps_name(c)?))), + ) + }); + own_codes + .then(|| numbered_glyph_name_at_its_code(name, code)) + .flatten() + }) + } + + fn code_to_ps_name(&self, code: u8) -> Option<&str> { + match &self.1 { Kind::Standard(s) => s.code_to_ps_name(code), Kind::Cff(c) => c.code_to_ps_name(code), Kind::Type1(t) => t.code_to_ps_name(code), - }?; - glyph_name_to_text(name) + } } } diff --git a/vendor/hayro-interpret/src/font/type3.rs b/vendor/hayro-interpret/src/font/type3.rs index c0e0d0b2df..e5c313ab7e 100644 --- a/vendor/hayro-interpret/src/font/type3.rs +++ b/vendor/hayro-interpret/src/font/type3.rs @@ -1,9 +1,14 @@ use crate::CMapResolverFn; -use crate::context::Context; +use crate::context::{ + Context, HeldContent, MAX_CACHED_TYPE3_PROCS, Type3Procs, content_left, hold_content, +}; use crate::device::Device; use crate::font::glyph_simulator::GlyphSimulator; use crate::font::true_type::{Width, read_encoding, read_widths}; -use crate::font::{Encoding, Glyph, Type3Glyph, UNITS_PER_EM, read_to_unicode}; +use crate::font::{ + Encoding, Glyph, Type3Glyph, UNITS_PER_EM, numbered_glyph_name_at_its_code, + numbered_names_name_their_codes, read_to_unicode, +}; use crate::interpret::state::TextState; use crate::soft_mask::SoftMask; use crate::util::RectExt; @@ -19,12 +24,18 @@ use hayro_syntax::object::dict::keys::{CHAR_PROCS, FONT_BBOX, FONT_MATRIX, RESOU use hayro_syntax::page::Resources; use kurbo::{Affine, BezPath, Rect}; use skrifa::GlyphId; +use std::cell::{OnceCell, RefCell}; use std::collections::HashMap; +use std::rc::Rc; +use std::sync::OnceLock; #[derive(Debug)] pub(crate) struct Type3<'a> { widths: Vec, missing_width: f32, + /// PdfCraft patch (90): the width each code's glyph procedure gives with `d0`/`d1`, read + /// the first time the code is shown in a font without `/Widths`. One cell per code. + proc_widths: Vec>>, encoding: Encoding, encodings: HashMap, dict: Dict<'a>, @@ -33,8 +44,23 @@ pub(crate) struct Type3<'a> { font_bbox: Rect, matrix: Affine, to_unicode: Option, + /// PdfCraft patch (87): glyph procedures already decoded, by glyph name, so a glyph shown + /// again and again (a Type 3 font used as a fill pattern draws one glyph hundreds of + /// thousands of times) isn't inflated again for every copy. Only procedures of at most + /// [`MAX_CACHED_PROC`] bytes are kept, and the fonts of one interpreter cache keep at most + /// `context::MAX_CACHED_TYPE3_PROCS` bytes together. + decoded_procs: RefCell>>, + /// PdfCraft patch: whether the font's numbered glyph names all name their own codes + /// (`numbered_names_name_their_codes`), worked out on first use. + own_code_names: OnceLock, } +/// PdfCraft patch (87): the largest decoded glyph procedure [`Type3`] keeps. +const MAX_CACHED_PROC: usize = 64 << 10; +/// PdfCraft patch (87): what a kept procedure costs the cache besides its bytes and its glyph +/// name (the map entry and the shared allocation's counts), so many empty procedures count too. +const CACHED_PROC_OVERHEAD: usize = 64; + impl<'a> Type3<'a> { pub(crate) fn new(dict: &Dict<'a>, cmap_resolver: &CMapResolverFn) -> Option { let (encoding, encodings) = read_encoding(dict); @@ -71,13 +97,57 @@ impl<'a> Type3<'a> { char_procs, widths, missing_width, + proc_widths: vec![OnceCell::new(); usize::from(u8::MAX) + 1], encodings, matrix, dict: dict.clone(), to_unicode, + decoded_procs: RefCell::new(HashMap::new()), + own_code_names: OnceLock::new(), }) } + /// PdfCraft patch (87): the decoded procedure of glyph `name`, held within the page's + /// content budget while the glyph is painted, whether it was decoded now or kept from an + /// earlier copy, so a page skips the same glyphs either way once the budget is spent. It is + /// decoded the first time and kept, when small enough and while `procs` has room, for the + /// next copies. Only a procedure decoded with more budget left than [`MAX_CACHED_PROC`] is + /// kept, so one the budget may have cut short isn't. + fn decoded_proc( + &self, + name: &str, + program: &Stream<'a>, + procs: &Type3Procs, + ) -> Option<(Rc<[u8]>, HeldContent)> { + let kept = self.decoded_procs.borrow().get(name).cloned(); + if let Some(bytes) = kept { + let held = hold_content(bytes.len())?; + return Some((bytes, held)); + } + let left = content_left(); + // PdfCraft patch: see `context::MAX_PAGE_CONTENT`. + let decoded = crate::context::decode_content(program)?; + procs.decodes.set(procs.decodes.get().saturating_add(1)); + let bytes: Rc<[u8]> = Rc::from(decoded.as_ref()); + drop(decoded); + let held = hold_content(bytes.len())?; + let cost = bytes + .len() + .saturating_add(name.len()) + .saturating_add(CACHED_PROC_OVERHEAD); + let total = procs.held.get().saturating_add(cost); + if left > MAX_CACHED_PROC + && bytes.len() <= MAX_CACHED_PROC + && total <= MAX_CACHED_TYPE3_PROCS + { + procs.held.set(total); + self.decoded_procs + .borrow_mut() + .insert(name.to_string(), bytes.clone()); + } + Some((bytes, held)) + } + pub(crate) fn map_code(&self, code: u8) -> GlyphId { self.encodings .get(&code) @@ -88,13 +158,53 @@ impl<'a> Type3<'a> { } pub(crate) fn glyph_width(&self, code: u8) -> f32 { + // PdfCraft patch (90): /Widths, /FirstChar and /LastChar are required for Type 3 fonts + // (Table 110), but a font without them gave every glyph no advance and its text was drawn on one spot. The glyph + // procedure's `d0`/`d1` operator states the glyph's width too (9.6.4), so such a font + // takes that width, as PDFium does. A font with /Widths is read as before: a code + // outside FirstChar..LastChar has width 0 (Table 110), as in Poppler and MuPDF. let w = match self.widths.get(code as usize).copied() { Some(Width::Value(w)) => w, + _ if self.widths.is_empty() => self.proc_width(code).unwrap_or(self.missing_width), _ => self.missing_width, }; (w * self.matrix.as_coeffs()[0] as f32) * UNITS_PER_EM } + /// PdfCraft patch (90): whether the font has no `/Widths` (or `/FirstChar`/`/LastChar`), so + /// that `glyph_width` falls back on the glyph procedures' `d0`/`d1` widths. + pub(crate) fn widths_missing(&self) -> bool { + self.widths.is_empty() + } + + /// PdfCraft patch (90): the horizontal width `wx` that the glyph procedure for `code` + /// sets with `d0` or `d1`, in glyph space. `None` when the code has no procedure, the + /// procedure sets no width or its width isn't finite. A procedure the page's content + /// budget can't decode isn't remembered, so a later page may still read it. + fn proc_width(&self, code: u8) -> Option { + let cell = self.proc_widths.get(usize::from(code))?; + if let Some(width) = cell.get() { + return *width; + } + let name = self.glyph_simulator.glyph_to_string(self.map_code(code))?; + let program = self.char_procs.get(&name)?; + let decoded = crate::context::decode_content(program)?; + let mut iter = TypedIter::new(decoded.as_ref()); + let mut width = None; + while let Some(op) = iter.next() { + match op { + TypedInstruction::ColorGlyph(d0) => width = Some(d0.0.as_f32()), + TypedInstruction::ShapeGlyph(d1) => width = Some(d1.0.as_f32()), + _ => continue, + } + break; + } + let width = width.filter(|w| w.is_finite()); + // Setting can't fail here: the cell was empty above and nothing in between fills it. + let _ = cell.set(width); + width + } + pub(crate) fn char_code_to_unicode(&self, char_code: u32) -> Option { // PdfCraft patch: upstream only consulted ToUnicode. Many Type3 fonts (e.g. TeX/dvips // bitmap fonts) have none; fall back to the /Encoding glyph names, then to printable @@ -115,9 +225,30 @@ impl<'a> Type3<'a> { name.and_then(crate::font::glyph_name_to_unicode) .or_else(|| (0x20..0x7f).contains(&code).then_some(code as char)) .map(BfString::Char) + // PdfCraft patch (100): above 126, a name that only numbers its glyph reads + // as the code's Latin-1 character when the number is that code and every + // numbered name in the font names its own code (`C174` at 174 reads `®`), + // as in a Type 1 font. + .or_else(|| { + let own_codes = *self.own_code_names.get_or_init(|| { + numbered_names_name_their_codes( + (0..=u8::MAX).filter_map(|c| Some((c, self.code_to_name(c)?))), + ) + }); + name.filter(|_| own_codes) + .and_then(|name| numbered_glyph_name_at_its_code(name, code)) + }) }) } + /// PdfCraft patch: the glyph name `/Encoding` gives a code, as `map_code` reads it. + fn code_to_name(&self, code: u8) -> Option<&str> { + self.encodings + .get(&code) + .map(String::as_str) + .or_else(|| self.encoding.map_code(code)) + } + pub(crate) fn render_glyph( &self, glyph: &Type3Glyph<'a>, @@ -144,9 +275,8 @@ impl<'a> Type3<'a> { let name = self.glyph_simulator.glyph_to_string(glyph.glyph_id)?; let program = self.char_procs.get(&name)?; - // PdfCraft patch: see `context::MAX_PAGE_CONTENT`. - let decoded = crate::context::decode_content(program)?; - let iter = TypedIter::new(decoded.as_ref()); + let (decoded, _held) = self.decoded_proc(&name, program, &glyph.cache.type3_procs)?; + let iter = TypedIter::new(&decoded); let is_shape_glyph = { let mut iter = iter.clone(); diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index c1bcc9e80d..4cb708761a 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -111,7 +111,7 @@ pub struct InterpreterSettings { /// PdfCraft patch: viewer overrides for optional content groups (object number, generation, /// visible), applied on top of the document's default configuration (Layers panel toggles). pub ocg_overrides: Arc>, - /// PdfCraft patch (76): apply the default configuration's auto state for the View event + /// PdfCraft patch (103): apply the default configuration's auto state for the View event /// (`/AS` usage applications with `/Event /View`, ISO 32000-2 §8.11.4.4) before /// `ocg_overrides`. On by default: that is the state for viewing. Off for output made for /// printing, which the View event is not. @@ -172,6 +172,10 @@ pub enum InterpreterWarning { /// read, or a Separation or DeviceN tint transform that cannot be evaluated) and was painted /// in a fallback colour. Reported at most once per content stream. ColorFallback, + /// PdfCraft patch (90): a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`), + /// which ISO 32000-2 Table 110 requires, was selected; its glyphs advance by the widths + /// their glyph procedures set with `d0`/`d1`. Reported each time such a font is selected. + Type3WidthsMissing, } /// PdfCraft patch (73): ends a page's JBIG2 allowance with the page, so that what decodes on its @@ -206,7 +210,15 @@ pub fn interpret_page<'a>( // PdfCraft patch (29): bracket the annotations so devices can tell their paints from // the document's own (the reading-colours setting recolours the document, not comments). device.begin_annotations(); + // PdfCraft patch (86): form fields' widgets are drawn after the page's other + // annotations, each group in `/Annots` order, as other readers draw them: a filled + // comment listed after a check box covered the box. ISO 32000-2 doesn't order the two. + // Two passes over the array, so nothing is held for every entry of a long one. + for widgets in [false, true] { for annot in annot_arr.iter::>() { + if (annot.get::>(SUBTYPE).as_deref() == Some(b"Widget")) != widgets { + continue; + } let flags = annot.get::(F).unwrap_or(0); // Annotation should be hidden (Hidden = bit 2, NoView = bit 6). @@ -322,6 +334,7 @@ pub fn interpret_page<'a>( draw_highlight_without_appearance(&annot, context, device); } } + } device.end_annotations(); } } diff --git a/vendor/hayro-interpret/src/ocg.rs b/vendor/hayro-interpret/src/ocg.rs index 4f0c090f61..7f5b9baba9 100644 --- a/vendor/hayro-interpret/src/ocg.rs +++ b/vendor/hayro-interpret/src/ocg.rs @@ -34,7 +34,7 @@ impl OcgState { } /// The groups' states as the document opens: the default configuration (`/BaseState`, then - /// `/ON` and `/OFF`), then, when `view_auto_state` is set (PdfCraft patch (76)), the auto + /// `/ON` and `/OFF`), then, when `view_auto_state` is set (PdfCraft patch (103)), the auto /// state its `/AS` array gives for the View event ([`apply_view_auto_state`]). pub(crate) fn from_catalog(catalog: &Dict<'_>, xref: &XRef, view_auto_state: bool) -> Self { let Some(oc_properties) = catalog.get::>(OCPROPERTIES) else { @@ -223,15 +223,15 @@ impl Default for OcgState { } } -/// PdfCraft patch (76): at most this many usage application dictionaries of an `/AS` array are +/// PdfCraft patch (103): at most this many usage application dictionaries of an `/AS` array are /// read; the rest are ignored. const MAX_AUTO_STATE_APPLICATIONS: usize = 256; -/// PdfCraft patch (76): at most this many `/OCGs` entries are read across all of them. +/// PdfCraft patch (103): at most this many `/OCGs` entries are read across all of them. const MAX_AUTO_STATE_GROUPS: usize = 4096; -/// PdfCraft patch (76): at most this many `/Category` names are read per application. +/// PdfCraft patch (103): at most this many `/Category` names are read per application. const MAX_AUTO_STATE_CATEGORIES: usize = 16; -/// PdfCraft patch (76): apply the configuration's auto state for the View event (ISO 32000-2 +/// PdfCraft patch (103): apply the configuration's auto state for the View event (ISO 32000-2 /// §8.11.4.4, Tables 99 and 101). For each usage application dictionary in `/AS` whose `/Event` /// is `/View`, each group in its `/OCGs` gets a recommended state from each category in its /// `/Category` that the group's `/Usage` gives one for. A group is ON only if every diff --git a/vendor/hayro-interpret/src/pattern.rs b/vendor/hayro-interpret/src/pattern.rs index 04750f3761..f76bfdf58b 100644 --- a/vendor/hayro-interpret/src/pattern.rs +++ b/vendor/hayro-interpret/src/pattern.rs @@ -23,6 +23,10 @@ use hayro_syntax::xref::XRef; use kurbo::{Affine, BezPath, Rect, Shape}; use std::fmt::{Debug, Formatter}; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// PdfCraft patch (78): the source of [`TilingPattern::instance`]. +static NEXT_TILING_INSTANCE: AtomicU64 = AtomicU64::new(0); /// A PDF pattern. #[derive(Debug, Clone)] @@ -156,6 +160,8 @@ impl CacheKey for ShadingPattern { #[derive(Clone)] pub struct TilingPattern<'a> { cache_key: u128, + /// PdfCraft patch (78): see [`Self::instance`]. + instance: u64, ctx_bbox: Rect, /// The bbox of the tiling pattern. pub bbox: Rect, @@ -233,6 +239,7 @@ impl<'a> TilingPattern<'a> { Some(Self { cache_key, + instance: NEXT_TILING_INSTANCE.fetch_add(1, Ordering::Relaxed), bbox, x_step, y_step, @@ -310,6 +317,23 @@ impl<'a> TilingPattern<'a> { } } +impl TilingPattern<'_> { + /// PdfCraft patch (78): which setting of the pattern (`scn`/`SCN`) this is. Each setting is + /// numbered apart, and its copies keep the number. A coloured pattern's cell depends only on + /// what the setting captured (its stream, resources, settings and the bounding box then in + /// effect) and on the scale it is drawn at, so a renderer can draw the cell once per number + /// and scale for every fill of that setting. An uncoloured pattern's cell also takes the + /// colour and opacity of the paint, which copies can change. + pub fn instance(&self) -> u64 { + self.instance + } + + /// Whether the pattern is coloured (`/PaintType 1`). + pub fn is_coloured(&self) -> bool { + self.is_color + } +} + impl CacheKey for TilingPattern<'_> { fn cache_key(&self) -> u128 { self.cache_key diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index aa05c4f455..849213a7f8 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -596,7 +596,8 @@ impl<'a> ImageXObject<'a> { self.decode_key(cache, true, target_dimension), MAX_DECODED_IMAGE_BYTES, decoded_mask_size, - || decode_mask(self, target_dimension), + &self.warning_sink, + |sink| decode_mask(&self.reporting_to(sink), target_dimension), ) } @@ -616,7 +617,8 @@ impl<'a> ImageXObject<'a> { self.decode_key(cache, false, target_dimension), MAX_DECODED_IMAGE_BYTES, decoded_raster_size, - || decode_raster(self, target_dimension), + &self.warning_sink, + |sink| decode_raster(&self.reporting_to(sink), target_dimension), ) } @@ -645,13 +647,73 @@ impl<'a> ImageXObject<'a> { interpolate: self.interpolate, // PdfCraft patch (38): the same image drawn in a default colour space or without one. default_space: self.default_space, - // PdfCraft patch (32): see `decode_reads_target`. + // PdfCraft patch (32): see `decode_reads_target`; patch (82): see `has_larger_mask`. target: if self.decode_reads_target() { target_dimension + } else if self.has_larger_mask() { + target_dimension.map(size_bucket) } else { None }, preview: cache.preview, + shrunk_to: None, + }) + } + + /// PdfCraft patch (82): this soft mask (or `/Mask` image) for an image of `parent` samples + /// drawn at `target_dimension` device pixels. A mask larger than its image and more than + /// [`SOFT_MASK_SHRINK_FACTOR`] times larger than where it is drawn, on a side, is averaged down + /// to that size, and the result is cached at that size: drawn + /// through a form many times (a 2 × 2 image whose mask is 34,862 × 4,332, drawn 40 times on + /// one slide, pdf.js `issue16263.pdf`), it was decoded, copied and drawn at full size each + /// time, about 3.6 s apiece, and the page took minutes. Any other mask decodes as before; a mask + /// no larger than its image keeps its size, so the renderer still samples it onto the image's + /// grid. + pub(crate) fn decoded_soft_mask(&self, target_dimension: Option<(u32, u32)>, parent: (u32, u32)) -> Option { + let larger_than_image = self.width > parent.0 || self.height > parent.1; + let shrink_to = target_dimension.filter(|&(tw, th)| { + larger_than_image + && tw > 0 + && th > 0 + && (u64::from(self.width) > u64::from(tw) * SOFT_MASK_SHRINK_FACTOR + || u64::from(self.height) > u64::from(th) * SOFT_MASK_SHRINK_FACTOR) + }); + // Decoded within the image's own decode, as before: the image's cache entry holds it as + // its alpha, and patch (91) keeps that entry when the mask fails, so a second entry for + // the mask would only count it twice. + let Some((tw, th)) = shrink_to else { + return decode_mask(self, target_dimension); + }; + // Averaged down to the drawn size rounded up to a power of two (`size_bucket`), so draws a + // pixel apart share one mask and the renderer filters the rest, and only on the sides that + // are larger than that. + let (bw, bh) = size_bucket((tw, th)); + let to = (bw.min(self.width).max(1), bh.min(self.height).max(1)); + // Decoded in full and averaged down to `to`. A truncated stream decodes fewer rows than + // the dictionary says: never more target samples than decoded ones on a side. + let from_full = |image: &Self, to: (u32, u32)| { + let full = decode_mask(image, target_dimension)?; + Some(DecodedMask { luma: shrink_luma_within(full.luma, to)? }) + }; + let Some(cache) = &self.decoded_cache else { + return from_full(self, to); + }; + let key = |to| self.decode_key(cache, true, target_dimension).map(|key| DecodedImageKey { shrunk_to: Some(to), ..key }); + // One image drawn at several sizes (rotated and scaled copies on one slide) needs the + // mask at several sizes. Each comes from one intermediate averaged-down mask, cached at + // most `SOFT_MASK_INTERMEDIATE` on a side, so the full mask is decoded once, not once per + // size; a size larger than the intermediate comes from the full mask. + let between = (self.width.min(SOFT_MASK_INTERMEDIATE), self.height.min(SOFT_MASK_INTERMEDIATE)); + if to == between || to.0 > between.0 || to.1 > between.1 { + return cache.get_or_insert_with(key(to), MAX_DECODED_IMAGE_BYTES, decoded_mask_size, &self.warning_sink, |sink| { + from_full(&self.reporting_to(sink), to) + }); + } + cache.get_or_insert_with(key(to), MAX_DECODED_IMAGE_BYTES, decoded_mask_size, &self.warning_sink, |sink| { + let mid = cache.get_or_insert_with(key(between), MAX_DECODED_IMAGE_BYTES, decoded_mask_size, sink, |sink| { + from_full(&self.reporting_to(sink), between) + })?; + Some(DecodedMask { luma: shrink_luma_within(mid.luma, to)? }) }) } @@ -667,6 +729,29 @@ impl<'a> ImageXObject<'a> { || dict.get::>(MASK).is_some_and(|s| uses_jpx(&s)) } + /// PdfCraft patch (82): whether this image has a soft mask (or `/Mask` image) larger than + /// itself, which may be averaged down to the size the image is drawn at + /// (`decoded_soft_mask`): the decoded raster holds it as its alpha, so it depends on that size, + /// to the [`size_bucket`] it is averaged down to. + fn has_larger_mask(&self) -> bool { + let dict = self.stream.dict(); + let larger = |s: &Stream<'_>| { + let d = s.dict(); + // The dimension keys as `ImageXObject::new` reads them, abbreviations first. + let (w, h) = (d.get::(W).or_else(|| d.get::(WIDTH)), d.get::(H).or_else(|| d.get::(HEIGHT))); + w.is_some_and(|w| w > self.width) || h.is_some_and(|h| h > self.height) + }; + dict.get::>(SMASK).is_some_and(|s| larger(&s)) || dict.get::>(MASK).is_some_and(|s| larger(&s)) + } + + /// PdfCraft patch (91): this image, reporting its warnings to `sink`. + fn reporting_to(&self, sink: &WarningSinkFn) -> Self { + Self { + warning_sink: sink.clone(), + ..self.clone() + } + } + pub(crate) fn width(&self) -> u32 { self.width } @@ -690,6 +775,71 @@ impl<'a> ImageXObject<'a> { /// image pixels shared across tiles and scales. Past it the cache starts a fresh epoch. const MAX_DECODED_IMAGE_BYTES: usize = 64 << 20; +/// PdfCraft patch (82): how many times larger than where it is drawn, on a side, a soft mask must be +/// before it is averaged down to that size (`ImageXObject::decoded_soft_mask`). Below it the mask +/// keeps its own resolution, which the renderer filters as it draws. +const SOFT_MASK_SHRINK_FACTOR: u64 = 2; + +/// PdfCraft patch (82): the largest side of the intermediate mask that every smaller averaged-down +/// size of one soft mask is made from (`ImageXObject::decoded_soft_mask`): 4096 × 4096 one-byte +/// samples are 16 MiB, a quarter of the decoded-image budget, so it stays cached between draws. +const SOFT_MASK_INTERMEDIATE: u32 = 4096; + +/// PdfCraft patch (82): the size a large soft mask is averaged down to for an image drawn at +/// `(w, h)` device pixels: each side rounded up to a power of two. Draws of one image a pixel or so +/// apart (scaled by 0.36 and 0.36167, say) share one averaged mask and one cache entry, and a mask +/// is never averaged below where it is drawn, so the renderer shrinks the rest at most twofold. +fn size_bucket((w, h): (u32, u32)) -> (u32, u32) { + (w.checked_next_power_of_two().unwrap_or(u32::MAX), h.checked_next_power_of_two().unwrap_or(u32::MAX)) +} + +/// PdfCraft patch (82): `luma` averaged down to `to` (no larger than it on either side): each +/// target sample is the mean of the source samples whose centres fall in it. Source sample `x` +/// of `sw`, centred at `x + ½`, falls in target sample `⌊(2x + 1)·tw / 2sw⌋`. The only buffers are +/// the target's sums and counts, reserved fallibly; nothing is sized by the source's width. +fn shrink_luma(luma: LumaData, to: (u32, u32)) -> Option { + let (sw, sh) = (luma.width as usize, luma.height as usize); + let (tw, th) = (to.0 as usize, to.1 as usize); + if sw == 0 || sh == 0 || tw == 0 || th == 0 || tw > sw || th > sh || luma.data.len() != sw.checked_mul(sh)? { + return None; + } + let cells = tw.checked_mul(th)?; + let (mut sums, mut counts): (Vec, Vec) = (Vec::new(), Vec::new()); + sums.try_reserve_exact(cells).ok()?; + counts.try_reserve_exact(cells).ok()?; + sums.resize(cells, 0); + counts.resize(cells, 0); + // The target sample a source sample's centre falls in, along an axis of `src` samples + // shrunk to `dst`. `2·i + 1 < 2·src` and `dst ≤ src ≤ 2^32`, so the product fits a u64. + let centre = |i: usize, src: usize, dst: usize| -> usize { + let at = (2 * i as u64 + 1) * dst as u64 / (2 * src as u64); + (at as usize).min(dst - 1) + }; + for (y, row) in luma.data.chunks_exact(sw).enumerate() { + let base = centre(y, sh, th) * tw; + for (x, &v) in row.iter().enumerate() { + let i = base + centre(x, sw, tw); + if let (Some(sum), Some(count)) = (sums.get_mut(i), counts.get_mut(i)) { + *sum += u64::from(v); + *count += 1; + } + } + } + let data = sums.iter().zip(&counts).map(|(&sum, &count)| if count == 0 { 0 } else { (sum / u64::from(count)) as u8 }).collect(); + Some(LumaData { data, width: to.0, height: to.1, ..luma }) +} + +/// PdfCraft patch (82): `luma` averaged down to `to`, but never to more samples than it has on a +/// side (a truncated stream decodes fewer rows than its dictionary says), and returned as it is +/// when that is its own size. +fn shrink_luma_within(luma: LumaData, to: (u32, u32)) -> Option { + let to = (to.0.min(luma.width).max(1), to.1.min(luma.height).max(1)); + if to == (luma.width, luma.height) { + return Some(luma); + } + shrink_luma(luma, to) +} + /// PdfCraft patch: pixel bytes held by a decoded mask. fn decoded_mask_size(mask: &DecodedMask) -> usize { mask.luma.width as usize * mask.luma.height as usize @@ -1145,7 +1295,8 @@ fn resolve_alpha( { let obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None, None)?; - decode_mask(&obj, target_dimension).map(|decoded| decoded.luma) + // PdfCraft patch (82): averaged down when far larger than where it is drawn, and cached. + obj.decoded_soft_mask(target_dimension, (width, *height)).map(|decoded| decoded.luma) } else if let Some(color_key_mask) = dict.get::>(MASK) { let mut mask_data = vec![]; diff --git a/vendor/hayro-syntax/src/filter/jbig2.rs b/vendor/hayro-syntax/src/filter/jbig2.rs index 33a7bc4dd8..5c81b8b2b2 100644 --- a/vendor/hayro-syntax/src/filter/jbig2.rs +++ b/vendor/hayro-syntax/src/filter/jbig2.rs @@ -28,7 +28,9 @@ pub fn set_page_jbig2_work(work: u64) { } /// Pays for reading `bytes` of a stream and its globals out of what the page has left, or `None`. -/// An image that doesn't decode isn't kept, so a page drawing it again and again reads it again. +/// An image that doesn't decode isn't always kept (not past the page's allowance, nor drawn under a +/// transfer function: hayro-interpret patch 91), so a page drawing it again and again can read it +/// again. #[cfg(feature = "std")] fn charge_page_reading(bytes: usize) -> Option<()> { let mut left = PAGE_WORK_LEFT.with(core::cell::Cell::get); diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index 376d3bfb6c..73730a07a7 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -65,6 +65,9 @@ pub use renderer::{image_resampling_size, tiling_cell_pixels, tiling_cell_scale, /// PdfCraft patch: exported for its regression test. #[doc(hidden)] pub use renderer::set_skip_offscreen_paths; +/// PdfCraft patch (30): exported for its regression tests. +#[doc(hidden)] +pub use renderer::{OffscreenDraws, offscreen_draws}; /// A cache used by the renderer. /// diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 1e46e3b79e..7a9462b63c 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -637,6 +637,47 @@ pub fn set_skip_offscreen_paths(skip: bool) { SKIP_OFFSCREEN_PATHS.with(|s| s.set(skip)); } +/// PdfCraft patch (30): what the renders on this thread have drawn offscreen, for tests that check +/// a page's cost by counting rather than by timing it (see [`offscreen_draws`]). +#[doc(hidden)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct OffscreenDraws { + /// Tiling pattern cells drawn; a cell taken from `tiling_cells` isn't drawn again. + pub tiling_cells: u64, + /// The pixels of every pixmap drawn offscreen: tiling cells, masks, groups and the like. + pub pixels: u64, +} + +thread_local! { + /// PdfCraft patch (30): see [`OffscreenDraws`]. + static OFFSCREEN_DRAWS: std::cell::Cell = + const { std::cell::Cell::new(OffscreenDraws { tiling_cells: 0, pixels: 0 }) }; +} + +/// PdfCraft patch (30): exported for its regression tests (see [`OffscreenDraws`]). The counts +/// only grow; a test takes the difference across a render. +#[doc(hidden)] +pub fn offscreen_draws() -> OffscreenDraws { + OFFSCREEN_DRAWS + .try_with(std::cell::Cell::get) + .unwrap_or_default() +} + +/// PdfCraft patch (30): a `width` × `height` pixmap to draw offscreen, counted in +/// [`OffscreenDraws`]. Every pixmap the renderer renders into, besides the page, is made here. +fn offscreen_pixmap(width: u16, height: u16, tiling_cell: bool) -> Pixmap { + // Gone only while the thread exits, when there is nothing left to count for. + let _ = OFFSCREEN_DRAWS.try_with(|draws| { + let mut counts = draws.get(); + counts.pixels = counts + .pixels + .saturating_add(u64::from(width) * u64::from(height)); + counts.tiling_cells = counts.tiling_cells.saturating_add(u64::from(tiling_cell)); + draws.set(counts); + }); + Pixmap::new(width, height) +} + /// PdfCraft patch: whether a device-space box can paint a pixel of a `width` × `height` /// canvas. A box whose bounds miss the canvas is skipped without changing a pixel. /// Bounds that aren't finite are drawn as before. @@ -802,6 +843,9 @@ pub(crate) struct Renderer { /// render, every nested renderer starting empty) and so to one `preview` setting: a mask /// drawn for a preview, which may leave out tiny images, never reaches a final render. pub(crate) soft_mask_cache: HashMap, + /// PdfCraft patch (30): coloured tiling cells drawn during this render (see [`TilingCells`]). + /// Like `soft_mask_cache` it belongs to one renderer, so to one `preview` setting. + tiling_cells: TilingCells, pub(crate) outline_cache: Rc>>>, pub(crate) cur_mask: Option, pub(crate) in_type3_glyph: bool, @@ -815,6 +859,40 @@ pub(crate) struct Renderer { ink: InkState, } +/// PdfCraft patch (30): the cell pixmaps of coloured tiling patterns, keyed by the setting of the +/// pattern (`TilingPattern::instance`) and the cell's size and scale. A page that fills many +/// shapes with one pattern (pdf.js issue 8078 paints 73,047 stencil masks with a pattern whose +/// cell is 3.5 MB) drew and kept a cell per fill, which ran out of memory; the fills now share +/// one. A handful of entries covers a run of fills with one pattern, and bounds what the cache +/// keeps besides what the scene holds anyway. +#[derive(Default)] +struct TilingCells(Vec<(TilingCellKey, Arc)>); + +#[derive(Clone, Copy, PartialEq, Eq)] +struct TilingCellKey { + instance: u64, + /// Whether this is the bordered cell of a pattern whose step shows one cell (patch (20)). + one_cell: bool, + is_stroke: bool, + pixels: (u16, u16), + scale: (u64, u64), +} + +impl TilingCells { + const MAX_ENTRIES: usize = 8; + + fn get(&self, key: &TilingCellKey) -> Option> { + self.0.iter().find(|(k, _)| k == key).map(|(_, cell)| cell.clone()) + } + + fn insert(&mut self, key: TilingCellKey, cell: Arc) { + if self.0.len() >= Self::MAX_ENTRIES { + self.0.remove(0); + } + self.0.push((key, cell)); + } +} + /// PdfCraft patch (27): which inks a pass of a `DeviceCMYK` group draws, and whether the /// group asked for something a pass can't draw. /// @@ -913,6 +991,7 @@ impl Renderer { erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), + tiling_cells: TilingCells::default(), outline_cache: cache.outline_cache.clone(), cur_mask: None, in_type3_glyph: false, @@ -1019,6 +1098,7 @@ impl Renderer { erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), + tiling_cells: TilingCells::default(), outline_cache: self.outline_cache.clone(), cur_mask: None, in_type3_glyph: false, @@ -1027,7 +1107,7 @@ impl Renderer { image_transparency_stack: Vec::new(), ink: InkState::default(), }; - let mut mask_pix = Pixmap::new(self.ctx.width(), self.ctx.height()); + let mut mask_pix = offscreen_pixmap(self.ctx.width(), self.ctx.height(), false); let rgb_data = ImageData::Rgb(RgbData { data: dummy_rgb, width: alpha_data.width, @@ -1540,6 +1620,52 @@ impl Renderer { self.draw_pixmap(Arc::new(pixmap), quality, draw_transform); } + /// PdfCraft patch (30): the cell pixmap of tiling pattern `t`, `size` pixels drawn at + /// `initial_transform`; a cell with a `key` is drawn once and then taken from `tiling_cells`. + fn tiling_cell( + &mut self, + t: &hayro_interpret::pattern::TilingPattern<'_>, + key: Option, + size: (u16, u16), + initial_transform: Affine, + is_stroke: bool, + ) -> Arc { + if let Some(cell) = key.and_then(|key| self.tiling_cells.get(&key)) { + return cell; + } + let mut renderer = Self { + ctx: RenderContext::new_with(size.0, size.1, derive_settings(self.ctx.render_settings())), + // PdfCraft patch: a pattern cell is painted as one artwork, so its content is never + // remapped by the reading-colours setting. + color_replacement: None, + in_annotations: false, + knockout: Vec::new(), + erasing: false, + knockout_passes: Vec::new(), + erase_groups: 0, + cur_mask: None, + inside_pattern: true, + soft_mask_cache: HashMap::default(), + tiling_cells: TilingCells::default(), + outline_cache: self.outline_cache.clone(), + in_type3_glyph: false, + scaler: self.scaler, + preview: self.preview, + image_transparency_stack: Vec::new(), + ink: InkState::default(), + }; + t.interpret(&mut renderer, initial_transform, is_stroke); + let mut pix = offscreen_pixmap(size.0, size.1, true); + renderer.ctx.flush(); + let mut resources = vello_cpu::Resources::default(); + renderer.ctx.render_to_pixmap(&mut resources, &mut pix); + let pix = Arc::new(pix); + if let Some(key) = key { + self.tiling_cells.insert(key, pix.clone()); + } + pix + } + fn push_clip_path_inner(&mut self, clip_path: &BezPath, fill: FillRule) { let old_transform = *self.ctx.transform(); @@ -1585,6 +1711,7 @@ impl Renderer { in_annotations: self.in_annotations, inside_pattern: self.inside_pattern, soft_mask_cache: HashMap::default(), + tiling_cells: TilingCells::default(), outline_cache: self.outline_cache.clone(), cur_mask: None, in_type3_glyph: false, @@ -1609,7 +1736,7 @@ impl Renderer { if renderer.ink.unsupported { return None; } - let mut pix = Pixmap::new(width, height); + let mut pix = offscreen_pixmap(width, height, false); renderer.ctx.flush(); let mut resources = vello_cpu::Resources::default(); renderer.ctx.render_to_pixmap(&mut resources, &mut pix); @@ -1741,7 +1868,9 @@ impl Renderer { AlphaColor::from_rgba8(c[0], c[1], c[2], c[3]).into() } Paint::Pattern(p) => { - let path_transform = self.ctx.transform(); + // PdfCraft patch (30): a copy, so a tiling cell can be drawn and kept on `self`. + let current_transform = *self.ctx.transform(); + let path_transform = ¤t_transform; match *p { Pattern::Shading(s) => { @@ -1848,39 +1977,20 @@ impl Renderer { const BORDER: f64 = 1.0; let pix_width = ((bbox.width() as f32 * xs).ceil() as u16).saturating_add(2); let pix_height = ((bbox.height() as f32 * ys).ceil() as u16).saturating_add(2); - let mut renderer = Self { - ctx: RenderContext::new_with( - pix_width, - pix_height, - derive_settings(self.ctx.render_settings()), - ), - color_replacement: None, - in_annotations: false, - knockout: Vec::new(), - erasing: false, - knockout_passes: Vec::new(), - erase_groups: 0, - cur_mask: None, - inside_pattern: true, - soft_mask_cache: HashMap::default(), - outline_cache: self.outline_cache.clone(), - in_type3_glyph: false, - scaler: self.scaler, - preview: self.preview, - image_transparency_stack: Vec::new(), - ink: InkState::default(), - }; let initial_transform = Affine::translate((BORDER, BORDER)) * Affine::scale_non_uniform(xs as f64, ys as f64) * Affine::translate((-bbox.x0, -bbox.y0)); - t.interpret(&mut renderer, initial_transform, is_stroke); - let mut pix = Pixmap::new(pix_width, pix_height); - renderer.ctx.flush(); - let mut resources = vello_cpu::Resources::default(); - renderer.ctx.render_to_pixmap(&mut resources, &mut pix); + let key = t.is_coloured().then(|| TilingCellKey { + instance: t.instance(), + one_cell: true, + is_stroke, + pixels: (pix_width, pix_height), + scale: (u64::from(xs.to_bits()), u64::from(ys.to_bits())), + }); + let pix = self.tiling_cell(&t, key, (pix_width, pix_height), initial_transform, is_stroke); paint_transform = path_transform.inverse() * t.matrix * initial_transform.inverse(); let image = Image { - image: ImageSource::Pixmap(Arc::new(pix)), + image: ImageSource::Pixmap(pix), sampler: ImageSampler { x_extend: peniko::Extend::Pad, y_extend: peniko::Extend::Pad, @@ -1910,37 +2020,17 @@ impl Renderer { let scaled_width = bbox.width() * xs; let scaled_height = bbox.height() * ys; - let mut renderer = Self { - ctx: RenderContext::new_with( - pix_width, - pix_height, - derive_settings(self.ctx.render_settings()), - ), - // PdfCraft patch: a pattern cell is painted as one artwork, so its - // content is never remapped by the reading-colours setting. - color_replacement: None, - in_annotations: false, - knockout: Vec::new(), - erasing: false, - knockout_passes: Vec::new(), - erase_groups: 0, - cur_mask: None, - inside_pattern: true, - soft_mask_cache: HashMap::default(), - outline_cache: self.outline_cache.clone(), - in_type3_glyph: false, - scaler: self.scaler, - preview: self.preview, - image_transparency_stack: Vec::new(), - ink: InkState::default(), - }; let mut initial_transform = Affine::scale_non_uniform(xs, ys) * Affine::translate((-bbox.x0, -bbox.y0)); - t.interpret(&mut renderer, initial_transform, is_stroke); - let mut pix = Pixmap::new(pix_width, pix_height); - renderer.ctx.flush(); - let mut resources = vello_cpu::Resources::default(); - renderer.ctx.render_to_pixmap(&mut resources, &mut pix); + // PdfCraft patch (30): a coloured cell is drawn once per setting and scale. + let key = t.is_coloured().then(|| TilingCellKey { + instance: t.instance(), + one_cell: false, + is_stroke, + pixels: (pix_width, pix_height), + scale: (xs.to_bits(), ys.to_bits()), + }); + let pix = self.tiling_cell(&t, key, (pix_width, pix_height), initial_transform, is_stroke); // TODO: Fix these if t.x_step < 0.0 { @@ -1957,7 +2047,7 @@ impl Renderer { path_transform.inverse() * t.matrix * initial_transform.inverse(); let image = Image { - image: ImageSource::Pixmap(Arc::new(pix)), + image: ImageSource::Pixmap(pix), sampler: ImageSampler { x_extend: peniko::Extend::Repeat, y_extend: peniko::Extend::Repeat, @@ -2195,69 +2285,57 @@ impl<'a> Device<'a> for Renderer { self.ctx.set_fill_rule(old_rule); } Paint::Pattern(_) => { - let (width, height) = (self.ctx.width(), self.ctx.height()); let stencil_rect = Rect::new( 0.0, 0.0, stencil.width as f64, stencil.height as f64, ); - let mask_pix = { - let rgb_bytes = ImageData::Rgb(RgbData { - data: vec![ - 255; - stencil.width as usize - * stencil.height as usize - * 3 - ], - width: stencil.width, - height: stencil.height, - interpolate: stencil.interpolate, - scale_factors: stencil.scale_factors, - }); - let mut sub_renderer = Self { - ctx: RenderContext::new_with( - width, - height, - derive_settings(self.ctx.render_settings()), - ), - // PdfCraft patch: a stencil mask must keep its original - // coverage values, so its content is never remapped by - // the reading-colours setting. - color_replacement: None, - in_annotations: false, - knockout: Vec::new(), - erasing: false, - knockout_passes: Vec::new(), - erase_groups: 0, - inside_pattern: false, - soft_mask_cache: HashMap::default(), - outline_cache: self.outline_cache.clone(), - cur_mask: None, - in_type3_glyph: false, - scaler: self.scaler, - preview: self.preview, - image_transparency_stack: Vec::new(), - ink: InkState::default(), - }; - let mut sub_pix = Pixmap::new(width, height); - sub_renderer.ctx.set_transform(transform); - sub_renderer.draw_image(rgb_bytes, Some(stencil)); - sub_renderer.ctx.flush(); - let mut resources = vello_cpu::Resources::default(); - sub_renderer - .ctx - .render_to_pixmap(&mut resources, &mut sub_pix); - sub_pix + let Some(len) = source_byte_len( + stencil.width, + stencil.height, + 3, + MAX_IMAGE_PIXELS, + ) else { + log::warn!( + "Stencil mask dimensions exceeded the buffer limit; image skipped" + ); + return; }; - - self.ctx.push_layer( - None, - Some(self.ctx.blend_mode()), - None, - Some(Mask::new_luminance(&mask_pix)), - None, - ); + let Some(mut white) = image_buffer(len) else { + log::warn!( + "Stencil mask buffer could not be allocated; image skipped" + ); + return; + }; + white.resize(len, 255); + // PdfCraft patch (30): under a clip of the stencil's device + // rectangle, the pattern is painted into a layer of its own and + // cut to the stencil by a layer holding the stencil, composited + // with `Compose::DestIn`; a layer is only as large as the clip, + // so a draw costs what its rectangle covers. A luminance mask + // (which vello_cpu only takes at canvas size) cost a + // canvas-sized render, mask and composite per draw: a page of + // 73,047 patterned 16 × 16 stencils took minutes. The clip is + // padded past the rectangle so that a stencil snapped to whole + // device pixels lies inside it; the cut clears the padding. + let device_rect = (transform * stencil_rect.to_path(0.1)) + .bounding_box() + .inflate(2.0, 2.0); + if ![device_rect.x0, device_rect.y0, device_rect.x1, device_rect.y1] + .iter() + .all(|v| v.is_finite()) + { + log::warn!("Stencil mask has an invalid transform; image skipped"); + return; + } + let layer_clip = device_rect.to_path(0.1); + let blend_mode = self.ctx.blend_mode(); + self.ctx.set_transform(Affine::IDENTITY); + self.ctx.push_clip_layer(&layer_clip); + // Unclipped, so isolated: a layer with only a clip draws + // straight into what is under it, which the cut would clear. + self.ctx.push_layer(None, Some(blend_mode), None, None, None); self.ctx.set_transform(transform); // PdfCraft patch: a stencil mask is painted with the fill @@ -2275,6 +2353,37 @@ impl<'a> Device<'a> for Renderer { } } + // The stencil, as white ink under its own coverage, keeps the + // pattern where it paints and clears the rest of the clip. + let paint_blend = self.ctx.blend_mode(); + self.ctx.set_blend_mode(peniko::BlendMode::default()); + // The pattern left its own paint transform, which would place + // the stencil's pixels. + self.ctx.set_paint_transform(Affine::IDENTITY); + self.ctx.push_blend_layer(peniko::BlendMode::new( + Mix::Normal, + Compose::DestIn, + )); + self.ctx.set_transform(transform); + // The coverage is drawn as a stencil outside a Type 3 glyph is: + // a glyph's image rules (nearest shrink, padded frame) are for + // the glyph's own images. + let in_type3_glyph = std::mem::replace(&mut self.in_type3_glyph, false); + self.draw_image( + ImageData::Rgb(RgbData { + data: white, + width: stencil.width, + height: stencil.height, + interpolate: stencil.interpolate, + scale_factors: stencil.scale_factors, + }), + Some(stencil), + ); + self.in_type3_glyph = in_type3_glyph; + self.ctx.pop_layer(); + self.ctx.set_blend_mode(paint_blend); + + self.ctx.pop_layer(); self.ctx.pop_layer(); } }; @@ -2366,7 +2475,7 @@ impl<'a> Device<'a> for Renderer { return false; } - let mut result = Pixmap::new(width, height); + let mut result = offscreen_pixmap(width, height, false); for ((px, rgb), coverage) in result.data_mut().iter_mut().zip(rgb.chunks_exact(3)).zip(cmy.data()) { let a = u32::from(coverage.a); let premul = |v: u8| ((u32::from(v) * a + 127) / 255) as u8; @@ -2750,6 +2859,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei inside_pattern: false, cur_mask: None, soft_mask_cache: HashMap::default(), + tiling_cells: TilingCells::default(), outline_cache: Rc::new(std::cell::RefCell::new(HashMap::new())), in_type3_glyph: false, scaler: Scaler::new(ResamplingFunction::CatmullRom), @@ -2777,7 +2887,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei renderer.ctx.pop_layer(); } - let mut pix = Pixmap::new(width, height); + let mut pix = offscreen_pixmap(width, height, false); renderer.ctx.flush(); let mut resources = vello_cpu::Resources::default(); renderer.ctx.render_to_pixmap(&mut resources, &mut pix);