diff --git a/contributors/people.toml b/contributors/people.toml index b3cab8e04f..a627aa8ac6 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -191,3 +191,6 @@ sync_github_name = true [people.ha1des] sync_github_name = true + +[people.aqibmohd271] +sync_github_name = true diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index a7ddb98ad8..f6b294662a 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -289,16 +289,51 @@ pub struct PrintPage { pub emulsion_down: bool, } +// ISO 32000-1, Annex D.2: the standard Helvetica font below uses WinAnsiEncoding, +// not UTF-8. Escape non-ASCII bytes as three octal digits so the content stays ASCII +// and a following digit cannot become part of the escape. Other scripts still need +// an embedded font; retain the existing '?' fallback for unsupported characters. fn pdf_string(s: &str) -> String { let mut o = String::from("("); for c in s.chars() { - match c { - '(' | ')' | '\\' => { + let byte = match c { + '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' => c as u8, + '€' => 0x80, + '‚' => 0x82, + 'ƒ' => 0x83, + '„' => 0x84, + '…' => 0x85, + '†' => 0x86, + '‡' => 0x87, + 'ˆ' => 0x88, + '‰' => 0x89, + 'Š' => 0x8a, + '‹' => 0x8b, + 'Œ' => 0x8c, + 'Ž' => 0x8e, + '‘' => 0x91, + '’' => 0x92, + '“' => 0x93, + '”' => 0x94, + '•' => 0x95, + '–' => 0x96, + '—' => 0x97, + '˜' => 0x98, + '™' => 0x99, + 'š' => 0x9a, + '›' => 0x9b, + 'œ' => 0x9c, + 'ž' => 0x9e, + 'Ÿ' => 0x9f, + _ => b'?', + }; + match byte { + b'(' | b')' | b'\\' => { o.push('\\'); - o.push(c); + o.push(char::from(byte)); } - c if c.is_ascii() && !c.is_ascii_control() => o.push(c), - _ => o.push('?'), + 0x80..=0xff => o.push_str(&format!("\\{byte:03o}")), + _ => o.push(char::from(byte)), } } o.push(')'); diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index e0c5c8d55d..823746bafe 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -940,3 +940,30 @@ fn bad_bleed_is_an_error_not_a_panic() { assert!(matches!(r, Err(EngineError::BadParams { .. })), "bleed {bleed}: {r:?}"); } } + +#[test] +fn print_strings_keep_winansi_accents_and_punctuation() { + assert_eq!(pdf_string("Café – résumé"), r"(Caf\351 \226 r\351sum\351)"); + assert_eq!(pdf_string("€ ‘quote’ “double” • Œœ Šš Žž Ÿ"), r"(\200 \221quote\222 \223double\224 \225 \214\234 \212\232 \216\236 \237)"); + assert_eq!(pdf_string("é7"), r"(\3517)", "octal escapes must have three digits before a digit"); +} + +#[test] +fn print_strings_keep_pdf_delimiters_safe_and_fallback_bounded() { + assert_eq!(pdf_string(r"a(b)\c"), r"(a\(b\)\\c)"); + assert_eq!(pdf_string("\n\r\t\0\u{7f}\u{81}中😀"), "(????????)"); + assert_eq!(pdf_string(""), "()"); +} + +#[test] +fn printed_pdf_uses_winansi_for_labels_and_descriptions() { + let mut page = page_of(3, vec![255; 6], false); + page.label = Some("Café – résumé.psd".into()); + page.description = Some("Price: €20 (proof)".into()); + let pdf = print_pdf(&page); + assert!(String::from_utf8_lossy(&pdf).contains("/Encoding /WinAnsiEncoding")); + let content = pdf_content(&pdf); + assert!(content.contains(r"(Caf\351 \226 r\351sum\351.psd) Tj"), "{content}"); + assert!(content.contains(r"(Price: \20020 \(proof\)) Tj"), "{content}"); + assert!(content.is_ascii(), "encoded bytes are escaped, never UTF-8 text operands"); +}