M10: Export to Word (.docx), HTML and RTF (new export crate with a small zip writer): paragraphs, headings, bold/italic, images, page breaks; doc_export_office tool

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 09:40:46 -04:00
1 parent 4d60af8b1e
commit 70280a44d0
19 files changed
+673 -18

No files matched your search

Generated
+8
View File
@@ -3861,6 +3861,7 @@ dependencies = [
"printcraft-cos",
"printcraft-create",
"printcraft-edit",
"printcraft-export",
"printcraft-fonts",
"printcraft-forms",
"printcraft-js",
@@ -3877,6 +3878,13 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "printcraft-export"
version = "0.1.0"
dependencies = [
"flate2",
]
[[package]]
name = "printcraft-filters"
version = "0.1.0"
+1
View File
@@ -32,6 +32,7 @@ printcraft-ocr = { path = "crates/ocr", version = "0.1.0" }
printcraft-js = { path = "crates/js", version = "0.1.0" }
printcraft-compare = { path = "crates/compare", version = "0.1.0" }
printcraft-preflight = { path = "crates/preflight", version = "0.1.0" }
printcraft-export = { path = "crates/export", version = "0.1.0" }
printcraft-edit = { path = "crates/edit", version = "0.1.0" }
printcraft-create = { path = "crates/create", version = "0.1.0" }
printcraft-forms = { path = "crates/forms", version = "0.1.0" }
+10
View File
@@ -259,6 +259,16 @@ impl Automation {
Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns }))
}
pub(crate) fn doc_export_office(&self, a: &Args) -> Result<Value> {
let path = self.resolve(a.str("path")?, true)?;
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
let format = printcraft_engine::compare::OfficeFormat::from_extension(&ext)
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, html or rtf)")))?;
let bytes = self.doc(a)?.export_office(format);
write_atomic(&path, &bytes)?;
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() }))
}
fn pdfa_level(&self, a: &Args) -> Result<printcraft_engine::pdfa::Level> {
let l = a.opt_str("level")?.unwrap_or("2b");
printcraft_engine::pdfa::Level::from_id(l).ok_or_else(|| ToolError::InvalidArgs(format!("unknown PDF/A level {l:?} (2b or 3b)")))
+1
View File
@@ -514,6 +514,7 @@ impl Automation {
"doc_compare" => self.doc_compare(&a)?,
"action_list" => self.action_list()?,
"pdfa_verify" => self.pdfa_verify(&a)?,
"doc_export_office" => self.doc_export_office(&a)?,
"pdfa_convert" => self.pdfa_convert(&a)?,
"action_run" => self.action_run(&a)?,
"doc_compare_report" => self.doc_compare_report(&a)?,
+3
View File
@@ -759,6 +759,9 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
&["doc", "field", "event"],
)),
t("doc_export_office", "Export to Word, HTML or RTF", "Export a PDF ▸ Word (.docx), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: paragraphs in reading order, headings from larger text, bold and italic, images where they fall, a page break between pages.")
.cmd("export.docx")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
.ro()
.cmd("standards.pdfa")
+16
View File
@@ -1484,3 +1484,19 @@ fn pdfa_through_tools() {
assert!(r["issues"].as_array().unwrap().iter().all(|i| i["fixable"] == false), "{r}");
assert!(a.call("pdfa_verify", &json!({ "doc": doc, "level": "9z" })).is_err());
}
#[test]
fn exporting_to_word_html_and_rtf() {
let dir = workdir("office");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
for ext in ["docx", "html", "rtf"] {
let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("a.{ext}") }));
assert_eq!(r["format"], ext);
}
let html = std::fs::read_to_string(dir.join("a.html")).unwrap();
assert!(html.contains("Page 1") && html.contains("Page 3") && html.matches("<hr>").count() == 2, "{html}");
assert!(std::fs::read(dir.join("a.docx")).unwrap().starts_with(b"PK"));
assert!(std::fs::read_to_string(dir.join("a.rtf")).unwrap().contains("Page 2"));
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err());
}
+1
View File
@@ -26,6 +26,7 @@ printcraft-ocr = { workspace = true }
printcraft-js = { workspace = true }
printcraft-compare = { workspace = true }
printcraft-preflight = { workspace = true }
printcraft-export = { workspace = true }
printcraft-fonts = { workspace = true }
printcraft-create = { workspace = true }
printcraft-model = { workspace = true }
+4 -3
View File
@@ -65,16 +65,17 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
icon: "file-output",
hue: BLUE,
badge: None,
availability: Planned("M10"),
availability: Ready,
sections: &[ToolSection {
title: "Export to",
items: &[
item("Microsoft Word (.docx)", "file-text", "export.docx", Planned("M10")),
item("Microsoft Word (.docx)", "file-text", "export.docx", Ready),
item("Spreadsheet (.xlsx)", "grid-3x3", "export.xlsx", Planned("M10")),
item("Presentation (.pptx)", "presentation", "export.pptx", Planned("M10")),
item("Image (PNG)", "image", "export.image", Ready),
item("Export all images", "image", "export.all_images", Ready),
item("HTML web page", "file-symlink", "export.html", Planned("M10")),
item("HTML web page", "file-symlink", "export.html", Ready),
item("Rich Text Format (.rtf)", "file-text", "export.rtf", Ready),
item("Text (plain)", "type", "export.text", Ready),
item("PostScript / EPS", "file-down", "export.ps", Planned("M10")),
],
+3
View File
@@ -255,6 +255,9 @@ pub const COMMANDS: &[CommandSpec] = &[
c("optimize.reduce", "Reduce file size…", FILE, None, Document, "file-down"),
c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"),
c("export.text", "Export to text…", FILE, None, Document, "type"),
c("export.docx", "Export to Word…", FILE, None, Document, "file-text"),
c("export.html", "Export to HTML…", FILE, None, Document, "file-symlink"),
c("export.rtf", "Export to RTF…", FILE, None, Document, "file-text"),
c("app.preferences", "Preferences…", None, None, Nothing, "settings"),
c("tools.js_console", "JavaScript console…", None, Some(Shortcut::cmd("J")), Document, "square-terminal"),
c("tools.document_js", "Document JavaScripts…", None, None, Modification, "file-code"),
+77
View File
@@ -122,3 +122,80 @@ impl Session {
Ok(n)
}
}
/// Export a PDF ▸ Word, HTML or RTF.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OfficeFormat {
Docx,
Html,
Rtf,
}
impl OfficeFormat {
pub fn extension(self) -> &'static str {
match self {
OfficeFormat::Docx => "docx",
OfficeFormat::Html => "html",
OfficeFormat::Rtf => "rtf",
}
}
pub fn from_extension(ext: &str) -> Option<OfficeFormat> {
match ext.to_ascii_lowercase().as_str() {
"docx" => Some(OfficeFormat::Docx),
"html" | "htm" => Some(OfficeFormat::Html),
"rtf" => Some(OfficeFormat::Rtf),
_ => None,
}
}
}
impl crate::Document {
/// The pages as paragraphs and images (for Word, HTML and RTF export).
pub fn export_pages(&self) -> Vec<printcraft_export::Page> {
let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return Vec::new() };
self.info
.pages
.iter()
.enumerate()
.map(|(i, info)| {
let blocks = printcraft_edit::text_blocks(cos, i)
.unwrap_or_default()
.into_iter()
.filter(|b| !b.text.trim().is_empty())
.map(|b| {
let f = b.base_font.to_ascii_lowercase();
printcraft_export::Block {
text: b.text,
rect: b.rect,
size: b.size,
bold: f.contains("bold") || f.contains("black") || f.contains("heavy"),
italic: f.contains("italic") || f.contains("oblique"),
}
})
.collect();
let images = self
.page_images(i)
.iter()
.enumerate()
.filter_map(|(k, im)| {
let (ext, bytes) = self.page_image_file(i, k).ok()?;
Some(printcraft_export::Image { ext: if ext == "jpg" { "jpg" } else { "png" }, bytes, rect: im.rect })
})
.collect();
printcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images }
})
.collect()
}
/// The document as a Word, HTML or RTF file.
pub fn export_office(&self, format: OfficeFormat) -> Vec<u8> {
let pages = self.export_pages();
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
match format {
OfficeFormat::Docx => printcraft_export::docx(&pages, &title),
OfficeFormat::Html => printcraft_export::html(&pages, &title).into_bytes(),
OfficeFormat::Rtf => printcraft_export::rtf(&pages).into_bytes(),
}
}
}
+18
View File
@@ -1384,3 +1384,21 @@ fn actions_run_their_steps_on_files() {
assert!(page_texts(&s2, id)[0].contains("DRAFT"));
assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err());
}
#[test]
fn exporting_office_files_keeps_images() {
let mut s = Session::new();
let text = s.create_from_text("t", "Picture page").unwrap();
let id = s.open("t.pdf", None, text, None).unwrap();
let png = export::Exporter::new(s.get(id).unwrap()).png(0, 20.0).unwrap();
let pic = s.create_from_images(&[("p.png".into(), png)]).unwrap();
let pid = s.open("p.pdf", None, pic, None).unwrap();
let d = s.get(pid).unwrap();
assert_eq!(d.export_pages()[0].images.len(), 1);
let docx = d.export_office(compare::OfficeFormat::Docx);
assert!(docx.windows(16).any(|w| w == b"word/media/image"));
if let Ok(dir) = std::env::var("PRINTCRAFT_EXPORT_DIR") {
std::fs::write(format!("{dir}/pic.docx"), &docx).unwrap();
}
assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("data:image/png;base64,"));
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "printcraft-export"
description = "Export a PDF to HTML, Word (.docx) and RTF from its paragraphs and images"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
flate2.workspace = true
+341
View File
@@ -0,0 +1,341 @@
//! printcraft-export — Export a PDF ▸ Word, HTML, RTF (L4).
//!
//! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and
//! images (encoded bytes and box), in reading order. The writers turn that into a flowing
//! document: headings are the paragraphs set larger than the body text, and images sit where
//! they fall between paragraphs.
mod zip;
pub use zip::Zip;
/// A paragraph of a page.
#[derive(Clone, Debug, PartialEq)]
pub struct Block {
pub text: String,
/// [x0, y0, x1, y1] in user space (y up).
pub rect: [f64; 4],
/// Font size in points.
pub size: f64,
pub bold: bool,
pub italic: bool,
}
/// An image of a page.
#[derive(Clone, Debug, PartialEq)]
pub struct Image {
/// "png" or "jpg".
pub ext: &'static str,
pub bytes: Vec<u8>,
pub rect: [f64; 4],
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Page {
pub width: f64,
pub height: f64,
pub blocks: Vec<Block>,
pub images: Vec<Image>,
}
/// What goes into the output, in order.
enum Item<'a> {
Para(&'a Block, u8),
Img(&'a Image),
PageBreak,
}
/// The body text size: the size most of the document's characters are set in.
fn body_size(pages: &[Page]) -> f64 {
let mut by: Vec<(i64, usize)> = Vec::new();
for b in pages.iter().flat_map(|p| &p.blocks) {
let k = (b.size * 2.0).round() as i64;
match by.iter_mut().find(|e| e.0 == k) {
Some(e) => e.1 += b.text.len(),
None => by.push((k, b.text.len())),
}
}
by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0)
}
/// Heading level for a block: 1 for ≥ 1.6× the body size, 2 for ≥ 1.25×, 0 for body text.
fn level(b: &Block, body: f64) -> u8 {
let short = b.text.len() < 200;
if short && b.size >= body * 1.6 {
1
} else if short && (b.size >= body * 1.25 || (b.bold && b.size >= body && b.text.len() < 80 && !b.text.ends_with('.'))) {
2
} else {
0
}
}
fn items(pages: &[Page]) -> Vec<Item<'_>> {
let body = body_size(pages);
let mut out = Vec::new();
for (i, p) in pages.iter().enumerate() {
if i > 0 {
out.push(Item::PageBreak);
}
// Blocks and images by their top edge, top to bottom.
let mut parts: Vec<(f64, f64, Item)> = p.blocks.iter().map(|b| (b.rect[3], b.rect[0], Item::Para(b, level(b, body)))).collect();
parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im))));
parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1)));
out.extend(parts.into_iter().map(|x| x.2));
}
out
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
fn base64(data: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::with_capacity(data.len().div_ceil(3) * 4);
for c in data.chunks(3) {
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
s.push(A[(n >> 18) as usize & 63] as char);
s.push(A[(n >> 12) as usize & 63] as char);
s.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' });
s.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' });
}
s
}
/// One HTML file: headings and paragraphs, images inline, a rule between pages.
pub fn html(pages: &[Page], title: &str) -> String {
let mut s = format!(
"<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n<style>body{{max-width:46em;margin:2em auto;font-family:sans-serif;line-height:1.45}}img{{max-width:100%}}hr{{border:0;border-top:1px solid #ccc;margin:2em 0}}</style>\n</head>\n<body>\n",
esc(title)
);
for it in items(pages) {
match it {
Item::Para(b, lvl) => {
let mut t = esc(&b.text);
if b.italic {
t = format!("<em>{t}</em>");
}
if b.bold && lvl == 0 {
t = format!("<strong>{t}</strong>");
}
match lvl {
0 => s.push_str(&format!("<p>{t}</p>\n")),
l => s.push_str(&format!("<h{l}>{t}</h{l}>\n")),
}
}
Item::Img(im) => {
let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" };
s.push_str(&format!("<p><img alt=\"\" src=\"data:{mime};base64,{}\"></p>\n", base64(&im.bytes)));
}
Item::PageBreak => s.push_str("<hr>\n"),
}
}
s.push_str("</body>\n</html>\n");
s
}
/// 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> {
let mut body = String::new();
let mut media: Vec<(String, &Image)> = Vec::new();
let run = |b: &Block| {
let mut rpr = String::new();
if b.bold {
rpr.push_str("<w:b/>");
}
if b.italic {
rpr.push_str("<w:i/>");
}
rpr.push_str(&format!("<w:sz w:val=\"{}\"/>", (b.size * 2.0).round().clamp(2.0, 3276.0) as i64));
format!("<w:r><w:rPr>{rpr}</w:rPr><w:t xml:space=\"preserve\">{}</w:t></w:r>", esc(&b.text))
};
for it in items(pages) {
match it {
Item::Para(b, lvl) => {
let style = match lvl {
1 => "<w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr>",
2 => "<w:pPr><w:pStyle w:val=\"Heading2\"/></w:pPr>",
_ => "",
};
body.push_str(&format!("<w:p>{style}{}</w:p>", run(b)));
}
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).
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 (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}\"/>\
<a:graphic xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/picture\">\
<pic:pic xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\"><pic:nvPicPr><pic:cNvPr id=\"{n}\" name=\"{name}\"/><pic:cNvPicPr/></pic:nvPicPr>\
<pic:blipFill><a:blip r:embed=\"rIdImg{n}\"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>\
<pic:spPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"{cx}\" cy=\"{cy}\"/></a:xfrm><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>\
</a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"
));
media.push((name, im));
}
Item::PageBreak => body.push_str("<w:p><w:r><w:br w:type=\"page\"/></w:r></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
);
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\">\
<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/>\
<Default Extension=\"png\" ContentType=\"image/png\"/><Default Extension=\"jpg\" ContentType=\"image/jpeg\"/>\
<Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/>\
<Override PartName=\"/word/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml\"/>\
<Override PartName=\"/docProps/core.xml\" ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/>",
);
types.push_str("</Types>");
let rels = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\
<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/>\
<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties\" Target=\"docProps/core.xml\"/></Relationships>";
let mut doc_rels = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\
<Relationship Id=\"rIdStyles\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/>",
);
for (i, (name, _)) in media.iter().enumerate() {
doc_rels.push_str(&format!(
"<Relationship Id=\"rIdImg{}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\" Target=\"media/{name}\"/>",
i + 1
));
}
doc_rels.push_str("</Relationships>");
let styles = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<w:styles xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">\
<w:style w:type=\"paragraph\" w:default=\"1\" w:styleId=\"Normal\"><w:name w:val=\"Normal\"/><w:pPr><w:spacing w:after=\"120\"/></w:pPr></w:style>\
<w:style w:type=\"paragraph\" w:styleId=\"Heading1\"><w:name w:val=\"heading 1\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:pPr><w:keepNext/><w:spacing w:before=\"240\"/><w:outlineLvl w:val=\"0\"/></w:pPr><w:rPr><w:b/></w:rPr></w:style>\
<w:style w:type=\"paragraph\" w:styleId=\"Heading2\"><w:name w:val=\"heading 2\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:pPr><w:keepNext/><w:spacing w:before=\"200\"/><w:outlineLvl w:val=\"1\"/></w:pPr><w:rPr><w:b/></w:rPr></w:style>\
</w:styles>";
let core = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<cp:coreProperties xmlns:cp=\"http://schemas.openxmlformats.org/package/2006/metadata/core-properties\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>{}</dc:title></cp:coreProperties>",
esc(title)
);
let mut z = Zip::default();
z.add("[Content_Types].xml", types.as_bytes(), true);
z.add("_rels/.rels", rels.as_bytes(), true);
z.add("docProps/core.xml", core.as_bytes(), true);
z.add("word/document.xml", doc.as_bytes(), true);
z.add("word/styles.xml", styles.as_bytes(), true);
z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true);
for (name, im) in &media {
z.add(&format!("word/media/{name}"), &im.bytes, false);
}
z.finish()
}
fn rtf_text(s: &str) -> String {
let mut o = String::new();
for c in s.chars() {
match c {
'\\' | '{' | '}' => {
o.push('\\');
o.push(c);
}
c if (c as u32) < 128 => o.push(c),
c => {
// \uN with a ? fallback; UTF-16 units as signed 16-bit numbers.
let mut buf = [0u16; 2];
for u in c.encode_utf16(&mut buf) {
o.push_str(&format!("\\u{}?", *u as i16));
}
}
}
}
o
}
/// Rich Text Format: paragraphs with their sizes and bold/italic, page breaks between pages
/// (images are left out).
pub fn rtf(pages: &[Page]) -> String {
let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n");
for it in items(pages) {
match it {
Item::Para(b, _) => {
let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64);
if b.bold {
fmt.push_str("\\b");
}
if b.italic {
fmt.push_str("\\i");
}
s.push_str(&format!("{{\\pard{fmt} {}\\par}}\n", rtf_text(&b.text)));
}
Item::Img(_) => {}
Item::PageBreak => s.push_str("\\page\n"),
}
}
s.push('}');
s
}
#[cfg(test)]
mod tests {
use super::*;
fn page() -> Page {
let b = |t: &str, y: f64, size: f64, bold: bool| Block { text: t.into(), rect: [72.0, y, 500.0, y + size], size, bold, italic: false };
Page {
width: 612.0,
height: 792.0,
blocks: vec![
b("Body text after the picture, which is long enough to be the main text size of the page.", 400.0, 11.0, false),
b("Annual Report", 700.0, 24.0, true),
b("Overview", 650.0, 14.0, true),
b("Some <body> text & more of it so that eleven points is the body size here.", 620.0, 11.0, false),
],
images: vec![Image {
ext: "png",
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(),
rect: [72.0, 450.0, 272.0, 600.0],
}],
}
}
#[test]
fn html_has_headings_paragraphs_and_images_in_order() {
let h = html(&[page(), page()], "Report");
let order: Vec<usize> = ["<h1>Annual Report</h1>", "<h2>Overview</h2>", "<p>Some &lt;body&gt; text &amp;", "<img", "<p>Body text after"]
.iter()
.map(|x| h.find(x).unwrap_or_else(|| panic!("{x} missing in {h}")))
.collect();
assert!(order.windows(2).all(|w| w[0] < w[1]), "{order:?}");
assert_eq!(h.matches("<hr>").count(), 1);
assert_eq!(base64(b"Man"), "TWFu");
assert_eq!(base64(b"Ma"), "TWE=");
}
#[test]
fn docx_is_a_word_package() {
let d = docx(&[page()], "Report");
assert!(d.starts_with(b"PK"));
let names: Vec<&str> =
["[Content_Types].xml", "word/document.xml", "word/styles.xml", "word/_rels/document.xml.rels", "word/media/image1.png", "_rels/.rels"]
.to_vec();
for n in names {
assert!(d.windows(n.len()).any(|w| w == n.as_bytes()), "{n}");
}
}
#[test]
fn rtf_escapes_and_sizes() {
let mut p = page();
p.blocks.push(Block { text: "Café {x}".into(), rect: [72.0, 100.0, 200.0, 110.0], size: 10.0, bold: false, italic: true });
let r = rtf(&[p]);
assert!(r.starts_with("{\\rtf1") && r.ends_with('}'));
assert!(r.contains("\\fs48\\b Annual Report"));
assert!(r.contains("\\fs20\\i Caf\\u233? \\{x\\}"), "{r}");
}
}
+110
View File
@@ -0,0 +1,110 @@
//! A minimal zip writer (deflate or store, no zip64) for Office files.
use std::io::Write;
fn crc32(data: &[u8]) -> u32 {
static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new();
let table = TABLE.get_or_init(|| {
let mut t = [0u32; 256];
for (i, e) in t.iter_mut().enumerate() {
let mut c = i as u32;
for _ in 0..8 {
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
*e = c;
}
t
});
let mut c = 0xFFFF_FFFFu32;
for b in data {
c = table[((c ^ *b as u32) & 0xFF) as usize] ^ (c >> 8);
}
c ^ 0xFFFF_FFFF
}
/// Builds a zip archive in memory.
#[derive(Default)]
pub struct Zip {
out: Vec<u8>,
central: Vec<u8>,
count: u16,
}
impl Zip {
/// Add a file. Already-compressed data (images) is stored; the rest is deflated.
pub fn add(&mut self, name: &str, data: &[u8], compress: bool) {
let crc = crc32(data);
let (method, body) = if compress {
let mut e = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
let _ = e.write_all(data);
(8u16, e.finish().unwrap_or_default())
} else {
(0u16, data.to_vec())
};
let offset = self.out.len() as u32;
let header = |sig: u32, central: bool| {
let mut h = Vec::new();
h.extend(sig.to_le_bytes());
if central {
h.extend(20u16.to_le_bytes()); // made by
}
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(crc.to_le_bytes());
h.extend((body.len() as u32).to_le_bytes());
h.extend((data.len() as u32).to_le_bytes());
h.extend((name.len() as u16).to_le_bytes());
h.extend(0u16.to_le_bytes()); // extra
if central {
h.extend(0u16.to_le_bytes()); // comment
h.extend(0u16.to_le_bytes()); // disk
h.extend(0u16.to_le_bytes()); // internal attributes
h.extend(0u32.to_le_bytes()); // external attributes
h.extend(offset.to_le_bytes());
}
h.extend(name.as_bytes());
h
};
self.out.extend(header(0x0403_4b50, false));
self.out.extend(&body);
self.central.extend(header(0x0201_4b50, true));
self.count += 1;
}
pub fn finish(mut self) -> Vec<u8> {
let start = self.out.len() as u32;
let size = self.central.len() as u32;
self.out.append(&mut self.central);
self.out.extend(0x0605_4b50u32.to_le_bytes());
self.out.extend([0u8; 4]);
self.out.extend(self.count.to_le_bytes());
self.out.extend(self.count.to_le_bytes());
self.out.extend(size.to_le_bytes());
self.out.extend(start.to_le_bytes());
self.out.extend(0u16.to_le_bytes());
self.out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crc_matches_the_reference() {
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
}
#[test]
fn archive_has_both_directories() {
let mut z = Zip::default();
z.add("a.txt", b"hello hello hello", true);
z.add("b.png", b"\x89PNG", false);
let b = z.finish();
assert!(b.starts_with(b"PK\x03\x04"));
assert_eq!(b.windows(4).filter(|w| *w == b"PK\x01\x02").count(), 2);
assert_eq!(&b[b.len() - 22..b.len() - 18], b"PK\x05\x06");
}
}
+3
View File
@@ -361,6 +361,9 @@ impl PrintCraftApp {
}
"form.export_data" => self.export_data_dialog(false, true),
"form.merge_data" => self.merge_data_dialog(),
"export.docx" => self.export_office_dialog(printcraft_engine::compare::OfficeFormat::Docx),
"export.html" => self.export_office_dialog(printcraft_engine::compare::OfficeFormat::Html),
"export.rtf" => self.export_office_dialog(printcraft_engine::compare::OfficeFormat::Rtf),
"form.prepare" => {
self.left = crate::LeftPanel::Tool("form");
self.left_open = true;
+29
View File
@@ -391,6 +391,35 @@ impl PrintCraftApp {
}
/// Export all comments / form data: the format follows the file name's extension.
/// Export a PDF ▸ Word, HTML or RTF: ask where (`save_override` in tests), then write.
pub fn export_office_dialog(&mut self, format: printcraft_engine::compare::OfficeFormat) {
let Some((_, id)) = self.active_ids() else { return };
let Some(doc) = self.session.get(id) else { return };
let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string();
let ext = format.extension();
let bytes = doc.export_office(format);
#[cfg(not(target_arch = "wasm32"))]
{
let path = match self.save_override.clone() {
Some(p) => Some(std::path::PathBuf::from(p)),
None => rfd::FileDialog::new()
.set_title("Export")
.add_filter(ext.to_uppercase(), &[ext])
.set_file_name(format!("{stem}.{ext}"))
.save_file(),
};
let Some(path) = path else { return };
match crate::editing::write_atomically(&path.to_string_lossy(), &bytes) {
Ok(()) => self.notify(format!("Exported to {}", path.display())),
Err(e) => self.notify(format!("Couldn't write {}: {e}", path.display())),
}
}
#[cfg(target_arch = "wasm32")]
if let Err(e) = crate::editing::download(&format!("{stem}.{ext}"), &bytes) {
self.notify(e);
}
}
/// Forms ▸ Merge data files into spreadsheet: choose data files (FDF, XFDF or filled-in PDF
/// forms), then where to save the CSV.
pub fn merge_data_dialog(&mut self) {
+3
View File
@@ -56,6 +56,9 @@ const PICKERS: &[&str] = &[
"a11y.report",
"ocr.recognize_batch",
"form.merge_data",
"export.docx",
"export.html",
"export.rtf",
];
#[test]
+16
View File
@@ -86,3 +86,19 @@ fn pdfa_dialog_verifies_and_converts() {
let id = h.state().active_ids().unwrap().1;
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Save as PDF/A-2b"));
}
#[test]
fn export_to_word_html_and_rtf() {
let doc = Session::new().create_from_text("t", "Exported words").unwrap().to_vec();
let dir = std::env::temp_dir().join(format!("printcraft-office-ui-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut app = PrintCraftApp::new();
app.open_bytes("e.pdf", None, doc).unwrap();
for ext in ["docx", "html", "rtf"] {
let out = dir.join(format!("e.{ext}"));
app.save_override = Some(out.to_string_lossy().into_owned());
assert!(app.execute(&format!("export.{ext}")));
assert!(std::fs::metadata(&out).unwrap().len() > 40, "{ext}");
}
assert!(std::fs::read_to_string(dir.join("e.html")).unwrap().contains("Exported words"));
}
+15 -15
View File
@@ -6823,11 +6823,11 @@ title = "Export to Word (.docx)"
area = "J"
tier = "P1"
milestone = "M10"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["export.docx"]
tools = ["doc_export_office"]
evidence = ["crates/export/src/lib.rs::docx_is_a_word_package", "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; checked with macOS textutil. Not yet: tables, columns, fonts and colours, exact layout mode."
[[feature]]
id = "create.export-rtf"
@@ -6835,11 +6835,11 @@ title = "Export to RTF"
area = "J"
tier = "P1"
milestone = "M10"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
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"]
notes = "Paragraphs with sizes, bold, italic, Unicode; page breaks. Images are left out."
[[feature]]
id = "create.export-xlsx"
@@ -6919,11 +6919,11 @@ title = "Export to HTML"
area = "J"
tier = "P1"
milestone = "M10"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["export.html"]
tools = ["doc_export_office"]
evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
notes = "Single HTML file with headings, paragraphs and inline images. Not yet: multi-page folder output, tables, CSS from fonts."
[[feature]]
id = "create.export-text"