diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..43805ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | PDF export writes the document title and author as UTF-16BE when they are not ASCII, so viewers show accents, currency signs and non-Latin text correctly (#813) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 51c2df1..5cc4a83 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -516,8 +516,23 @@ fn run_length(data: &[u8]) -> Vec { out } -fn pdf_text_string(s: &str) -> String { - pdf_string(s) +/// Longest Info string written, in characters; longer values are cut on a character boundary. +const MAX_INFO_CHARS: usize = 1024; + +/// A PDF text string (document Info entries) for `text`: a literal `( … )` for printable ASCII, +/// otherwise UTF-16BE with a byte-order mark as a hex string (ISO 32000-1 7.9.2.2). +fn pdf_text_string(text: &str) -> String { + let text: String = text.chars().take(MAX_INFO_CHARS).collect(); + if text.chars().all(|c| matches!(c, ' '..='~')) { + return pdf_string(&text); + } + let mut s = String::with_capacity(text.len() * 4 + 6); + s.push_str("'); + s } impl PdfDoc { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e7b488a..4ea9170 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -157,3 +157,43 @@ fn hostile_inputs_never_panic() { let pdf = d.finish(); check_xref(&pdf); } + +/// Decodes the `` hex string following `key` in the file bytes. +fn utf16_entry(pdf: &[u8], key: &str) -> String { + let at = find(pdf, key.as_bytes()).unwrap() + key.len(); + let rest = std::str::from_utf8(&pdf[at..at + 9000.min(pdf.len() - at)]).unwrap_or(""); + let hex = rest.trim_start().strip_prefix("').unwrap()]; + let units: Vec = (0..hex.len()).step_by(4).map(|i| u16::from_str_radix(&hex[i..i + 4], 16).unwrap()).collect(); + String::from_utf16(&units).unwrap() +} + +#[test] +fn info_strings_with_non_ascii_are_utf16be() { + let mut doc = PdfDoc::new(); + doc.add_page(200.0, 200.0); + doc.info.title = "Übersicht €5 – “Q1” 日本 😀".into(); + doc.info.author = "Müller & Søn".into(); + let pdf = doc.finish(); + assert_eq!(utf16_entry(&pdf, "/Title"), "Übersicht €5 – “Q1” 日本 😀"); + assert_eq!(utf16_entry(&pdf, "/Author"), "Müller & Søn"); +} + +#[test] +fn ascii_info_strings_stay_literal_and_escaped() { + let mut doc = PdfDoc::new(); + doc.add_page(200.0, 200.0); + doc.info.title = r"Q1 (draft) a\b".into(); + let pdf = doc.finish(); + assert!(find(&pdf, br"/Title (Q1 \(draft\) a\\b)").is_some()); + assert!(find(&pdf, b"/Producer (GridCraft PDF)").is_some()); +} + +#[test] +fn info_strings_are_capped() { + let mut doc = PdfDoc::new(); + doc.add_page(200.0, 200.0); + doc.info.title = "日".repeat(100_000); + let pdf = doc.finish(); + assert_eq!(utf16_entry(&pdf, "/Title").chars().count(), MAX_INFO_CHARS); +}