mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 05:28:09 -06:00
Merge pull request #78 from storytold/fix/72-word-export
Export to Word: files open in Word again (#72)
This commit is contained in:
+75
-10
@@ -307,8 +307,25 @@ fn items(pages: &[Page]) -> Vec<Item<'_>> {
|
||||
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("<w:i/>");
|
||||
}
|
||||
rpr.push_str(&format!("<w:sz w:val=\"{}\"/>", (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!("<w:sz w:val=\"{half_points}\"/>"));
|
||||
format!("<w:r><w:rPr>{rpr}</w:rPr><w:t xml:space=\"preserve\">{}</w:t></w:r>", 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<u8> {
|
||||
// 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<u8> {
|
||||
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!(
|
||||
"<w:p><w:r><w:drawing><wp:inline><wp:extent cx=\"{cx}\" cy=\"{cy}\"/><wp:docPr id=\"{n}\" name=\"Picture {n}\"/>\
|
||||
@@ -483,15 +508,13 @@ pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
if after_table {
|
||||
body.push_str("<w:p/>");
|
||||
}
|
||||
// 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!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
|
||||
<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" \
|
||||
xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\"><w:body>{body}\
|
||||
<w:sectPr><w:pgSz w:w=\"{}\" w:h=\"{}\"/><w:pgMar w:top=\"1440\" w:right=\"1440\" w:bottom=\"1440\" w:left=\"1440\" w:header=\"720\" w:footer=\"720\" w:gutter=\"0\"/></w:sectPr></w:body></w:document>",
|
||||
(pw * 20.0) as i64,
|
||||
(ph * 20.0) as i64
|
||||
<w:sectPr><w:pgSz w:w=\"{pw}\" w:h=\"{ph}\"/><w:pgMar w:top=\"{my}\" w:right=\"{mx}\" w:bottom=\"{my}\" w:left=\"{mx}\" w:header=\"{}\" w:footer=\"{}\" w:gutter=\"0\"/></w:sectPr></w:body></w:document>",
|
||||
my / 2,
|
||||
my / 2
|
||||
);
|
||||
let mut types = String::from(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\
|
||||
@@ -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</w:t>"), "{xml}");
|
||||
assert!(!xml.chars().any(|c| !allowed(c)));
|
||||
assert!(xml.contains("<w:pgSz w:w=\"12240\" w:h=\"31680\"/>"), "clamped to 22 in: {xml}");
|
||||
assert!(xml.contains("<w:sz w:val=\"24\"/>"), "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("<w:pgMar w:top=\"500\" w:right=\"250\" w:bottom=\"500\" w:left=\"250\""), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_escapes_and_sizes() {
|
||||
let mut p = page();
|
||||
|
||||
@@ -51,7 +51,7 @@ impl Zip {
|
||||
h.extend(20u16.to_le_bytes()); // version needed
|
||||
h.extend(0x0800u16.to_le_bytes()); // UTF-8 names
|
||||
h.extend(method.to_le_bytes());
|
||||
h.extend([0u8; 4]); // time, date
|
||||
h.extend([0, 0, 0x21, 0]); // 00:00, 1980-01-01 (a zero date is not a valid DOS date)
|
||||
h.extend(crc.to_le_bytes());
|
||||
h.extend((body.len() as u32).to_le_bytes());
|
||||
h.extend((data.len() as u32).to_le_bytes());
|
||||
|
||||
@@ -6850,8 +6850,8 @@ milestone = "M10"
|
||||
status = "partial"
|
||||
commands = ["export.docx"]
|
||||
tools = ["doc_export_office"]
|
||||
evidence = ["crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
|
||||
notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, images inline, page breaks, and detected tables with spans/empty cells; checked with macOS textutil. Not yet: columns, fonts and colours, exact layout mode."
|
||||
evidence = ["crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
|
||||
notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, images inline, page breaks, and detected tables with spans/empty cells; checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: columns, fonts and colours, exact layout mode."
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-rtf"
|
||||
@@ -6862,7 +6862,7 @@ milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["export.rtf"]
|
||||
tools = ["doc_export_office"]
|
||||
evidence = ["crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
|
||||
evidence = ["crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
|
||||
notes = "Paragraphs with sizes, bold, italic, Unicode; page breaks. Images are left out."
|
||||
|
||||
[[feature]]
|
||||
|
||||
Reference in New Issue
Block a user