Merge pull request #814 from vangel76:fix/pdf-info-strings-utf16

This commit is contained in:
Brandon Thomas 2026-10-13 00:08:39 -04:00
commit be4d754ef4
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 | `TEXT()` over a range and chart labels parse each number format code once instead of once per value (#806) |
| 2026-10-12 | SUBTOTAL(109) over hidden rows recalculates after Row Height, AutoFit Row Height and Ungroup reveal rows (found by the command-sequence fuzz) |
| 2026-10-12 | Page Layout view: drag the margin edges on the rulers to set the page margins (resize cursor, live preview, value tooltip, one undo step, Esc cancels) (#792) |

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);
}