PDF: title and author are written as UTF-16BE when they are not ASCII (#813)

Info strings were WinAnsi; PDF text strings are PDFDocEncoding or UTF-16BE.
Printable ASCII stays a literal string; anything else is a <FEFF...> hex
string. Info strings are capped at 1024 characters.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-12 22:18:41 +02:00
parent fc45b7f6d8
commit 0042240c05
3 changed files with 58 additions and 2 deletions

View File

@ -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) |

View File

@ -516,8 +516,23 @@ fn run_length(data: &[u8]) -> Vec<u8> {
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("<FEFF");
for unit in text.encode_utf16() {
let _ = write!(s, "{unit:04X}");
}
s.push('>');
s
}
impl PdfDoc {

View File

@ -157,3 +157,43 @@ fn hostile_inputs_never_panic() {
let pdf = d.finish();
check_xref(&pdf);
}
/// Decodes the `<FEFF…>` 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("<FEFF").unwrap();
let hex = &hex[..hex.find('>').unwrap()];
let units: Vec<u16> = (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);
}