From 8b25d88450e6a5aa57df30d474df9b4f71800075 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 01:31:37 +0700 Subject: [PATCH] Export to Word: files open in Word again (#72) Text extracted from a PDF can hold C0 control characters (fonts without a usable /ToUnicode), which XML forbids, and Word refuses the whole document over one ("Word experienced an error trying to open the file"). The Word, HTML and RTF writers now drop the characters XML forbids. Also: - the page size stays within Word's 22 in (a till receipt is taller, and Word read it as a negative height), and margins shrink on small pages so text keeps room; images fit the real text width; - a non-finite font size falls back to 12 pt; - zip entries get a valid DOS date (1980-01-01) instead of zero. Checked by opening the exports in Microsoft Word: control characters, U+FFFE/U+FFFF, a 33 in receipt with a 40-row table, narrow, tiny and wide pages all open. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/lib.rs | 85 +++++++++++++++++++++++++++++++----- crates/export/src/zip.rs | 2 +- parity/acrobat-features.toml | 6 +-- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 0b9b889b5..5dd507669 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -307,8 +307,25 @@ fn items(pages: &[Page]) -> Vec> { out } +/// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, +/// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them +/// (fonts without a usable `/ToUnicode`), and Word refuses a whole document over one (#72). +fn allowed(c: char) -> bool { + matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}') +} + fn esc(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) + let mut o = String::with_capacity(s.len()); + for c in s.chars().filter(|c| allowed(*c)) { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + c => o.push(c), + } + } + o } fn base64(data: &[u8]) -> String { @@ -387,7 +404,8 @@ fn run_xml(text: &str, size: f64, bold: bool, italic: bool) -> String { if italic { rpr.push_str(""); } - rpr.push_str(&format!("", (size * 2.0).round().clamp(2.0, 3276.0) as i64)); + let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 }; + rpr.push_str(&format!("")); format!("{rpr}{}", esc(text)) } @@ -438,6 +456,13 @@ fn docx_table(t: &Table) -> String { /// A Word document (.docx, Office Open XML): Heading 1/2 and Normal paragraphs, images inline at /// their size on the page, page breaks between pages. pub fn docx(pages: &[Page], title: &str) -> Vec { + // The first page's size, within what Word accepts (0.1 to 22 in; a long receipt is taller), + // in twips. Margins are 1 in, less on small pages so text keeps room. + let twips = |pt: f64| if pt.is_finite() { (pt * 20.0).round().clamp(144.0, 31680.0) as i64 } else { 12240 }; + let (pw, ph) = pages.first().map_or((12240, 15840), |p| (twips(p.width), twips(p.height))); + let (mx, my) = ((pw / 8).min(1440), (ph / 8).min(1440)); + // Images are at most the text width, in points. + let text_w = (pw - 2 * mx) as f64 / 20.0; let mut body = String::new(); let mut media: Vec<(String, &Image)> = Vec::new(); // Word needs a paragraph between adjacent tables and after the last one in the body. @@ -462,9 +487,9 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { Item::Img(im) => { let n = media.len() + 1; let name = format!("image{n}.{}", im.ext); - // Size on the page, in EMU (12700 per point), at most the text width (6.5 in). + // Size on the page, in EMU (12700 per point), at most the text width. let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0)); - let k = (468.0 / w).min(1.0); + let k = (text_w / w).min(1.0); let (cx, cy) = ((w * k * 12700.0) as i64, (h * k * 12700.0) as i64); body.push_str(&format!( "\ @@ -483,15 +508,13 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { if after_table { body.push_str(""); } - // The first page's size and margins of 1 in. - let (pw, ph) = pages.first().map_or((612.0, 792.0), |p| (p.width, p.height)); let doc = format!( "\n\ {body}\ -", - (pw * 20.0) as i64, - (ph * 20.0) as i64 +", + my / 2, + my / 2 ); let mut types = String::from( "\n\ @@ -540,7 +563,7 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin fn rtf_text(s: &str) -> String { let mut o = String::new(); - for c in s.chars() { + for c in s.chars().filter(|c| allowed(*c)) { match c { '\\' | '{' | '}' => { o.push('\\'); @@ -659,6 +682,48 @@ mod tests { } } + /// One part of a package written by [`Zip`], inflated. + fn part(zip: &[u8], name: &str) -> String { + let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); + let mut i = 0; + while zip[i..].starts_with(b"PK\x03\x04") { + let (size, name_len) = (at(i + 18, 4), at(i + 26, 2)); + let data = i + 30 + name_len; + if &zip[i + 30..data] == name.as_bytes() { + let mut s = String::new(); + std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut s).unwrap(); + return s; + } + i = data + size; + } + panic!("{name} missing") + } + + #[test] + fn word_opens_text_with_control_characters_and_long_pages() { + // #72: text extracted without a usable /ToUnicode holds C0 controls, which XML forbids; + // Word then refused the whole file. A till receipt is also taller than Word's 22 in. + let mut p = page(); + p.height = 2400.0; + p.blocks.push(Block { + text: "Total\u{0}\u{3}\u{c} 4.50\u{FFFF}\tGBP".into(), + rect: [72.0, 100.0, 300.0, 110.0], + size: f64::NAN, + bold: false, + italic: false, + }); + let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml"); + assert!(xml.contains(">Total 4.50\tGBP"), "{xml}"); + assert!(!xml.chars().any(|c| !allowed(c))); + assert!(xml.contains(""), "clamped to 22 in: {xml}"); + assert!(xml.contains(""), "a non-finite size falls back to 12 pt"); + assert!(rtf(&[p]).contains("Total 4.50\tGBP")); + // A narrow page keeps room for text: margins shrink. + let narrow = Page { width: 100.0, height: 200.0, ..page() }; + let xml = part(&docx(&[narrow], "Narrow"), "word/document.xml"); + assert!(xml.contains("