mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
1 parent
4d60af8b1e
commit
70280a44d0
19 files changed
+673
-18
No files matched your search
Generated
+8
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)")))
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")),
|
||||
],
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,"));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
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 <body> text &", "<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}");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -56,6 +56,9 @@ const PICKERS: &[&str] = &[
|
||||
"a11y.report",
|
||||
"ocr.recognize_batch",
|
||||
"form.merge_data",
|
||||
"export.docx",
|
||||
"export.html",
|
||||
"export.rtf",
|
||||
];
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user