diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 0405f3d6d..531027276 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0c12b624e677e2ec61afa20333aa7b2e3e2303ab596b305269f4fcfb1183cdaa" +sha256 = "2fbefd1bf1532d096312019fff6b30ee71df599ac1d667a1728ab1205c17629a" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9ce8e70f139336bae217f6b96408d93b66fb3b7a2cae2c4df3e7473ba0f40d5e" +sha256 = "0da23df175b3f29ca3813e7dce4495a20eee69ca2ed02f8c36d00384a211929d" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d16f126aa4082aa7d22f539d0a0256c87f486615e9088f812f76c750b64b7c49" +sha256 = "1a9d76e9b82df6e3cc6df8b6360c096a5e326fa234b8d1b526a4209ddaecaf5b" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "60cfcc7003f50d5466a7e376ae5e4710e05833d7f30a5b82d7a866e802c479e6" +sha256 = "97f661e05021ae9ccce0160d88cdf06f7e7619e402cf3b7e7d3d8d783518d1d6" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "20f5e4d96efd67556c90d3b20cb8f224f3fcca7ccca679f79b28e8a785db4519" +sha256 = "44eaaedd905ffbf862f3a9635398dd41ab3b37d7a8e8fbce749cd8e273fa1b0c" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4073d6909780954808c27ed510f41b5f34d9037d435e8ecb9baa03b3dae34900" +sha256 = "d5457cae93a8cba63d1d2a1b2dbf9aab9c51851af3b9f750977b62640f3281ec" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fb7b933a43439715ac7a702bee483f007b2dd7c34a72805acb89786bc0c26d04" +sha256 = "865bd5cf347ea5beae0f0878a9a50ce4d9935909461c729fdf894964b3d9b9d6" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "82759859786d66d3c2e10cd57911d66834dd73e93c6bdbdf173956cb6c7c00a9" +sha256 = "0b8ba5098d96f58312526ca4c391cd598920afd8ac152c9464c0d892b76eeb43" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a0157cc0dc8a129f08a22f194b5bd57215f88b4c983af6725dd56d4a375b1f1b" +sha256 = "e7a7233c73fe54c581f8950a44a72c1e55062c65d8ad6a8726f02c7cd21cf6c2" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e3ce7cac5de5a4561e1b93072bce74a14651d7b3d49e13d2df8b125d6cb5a424" +sha256 = "6bebe9bb47b800fd854757044eac616c71adb0440abb67af053b651091d54cda" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "15328644bb17127f0df378ab7ece03edffcda6eb42438c0613e032dd20641aa0" +sha256 = "42eddd3036e369b45ff97736968dc5e9a008afbec1cbb3d9105a222e36d788ff" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f1df5b6a526dcf80e9d9917892508eb5eb229955dc5c704fd69cec3536241564" +sha256 = "65353bb79c5185e0063b88780996d92e4f123c5322316c5facf6f971bd8ca3cf" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b1d367feb23ccd7d2bd59691fa6a82ae7392545e7bfc00b0f00b3f430f0cf8d8" +sha256 = "dba065d79466a3a6a920fb90a5dd8deeebc581767bc3ce34dc4f03c1bb263514" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "48fefa761b28fafe7a0f07184d8280aae3eed6956d4a5291895f4158011894e3" +sha256 = "3466e2aebca881934ea764b9effa2806a64433de30c45caec12a02f4b5078f60" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0bc1061796a4bee05e817cd023d677231dba3ba0472d41d9584b2a06d4cdb34c" +sha256 = "8dd4720892e25b1aac2a50b5e4e1741b026d4a323bb3cf16f87ca40cf98936c2" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e34088eef6d6fe517da21dc4a32c4ffd72decf642f12f4c6dc50b794cccfb742" +sha256 = "337dcdc34b919e0b72b0c1520d765c750353120617c9c8e7698d13d3a2ba0efe" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a7eb1c513ec11a04d16bed1b3e165125ae5dd10fcec2b3caa8871eee21a56daf" +sha256 = "9e2bdc7e30d2168c167b5dcb067b200af2c8047097a32e0153995367ac844c4e" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1939aa38b6ce305c0cf1b84d247e6a49f948826163712ff789a3c339f3f7b488" +sha256 = "6b736d743819cf54cb0f5b3bac39ca8e044d6109300a9d5110d506100b8d4b68" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a8f52da804453baf6546941442dfc860a0ea3bb25e274037c3dab607887a2f29" +sha256 = "6d013ef469710b8213bae67c021a3915044fe16225f06c2463aaca41a1baddd1" [[asset]] path = "assets/icons/accessibility.svg" @@ -3339,4 +3339,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1e1d5662e8674698f2c526b12cea3eb280e6168512f77db32676e79e80a4c43b" +sha256 = "6ca8b9cf32e1de446b3bedb7cb79d1c8665889aa6eabfb05159bfd22b594c854" diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index fb2ab7928..5384127dc 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -391,7 +391,7 @@ impl Automation { 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 = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) - .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, html or rtf)")))?; + .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?; let bytes = self.doc_to_copy(a)?.export_office(format); write_atomic(&path, &bytes)?; Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() })) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index b650d5c55..740eb752e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -921,7 +921,7 @@ pub fn tools() -> Vec { 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: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.") + t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.") .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.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index fbdea64a5..c75b8be75 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3804,13 +3804,14 @@ 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"] { + for ext in ["docx", "pptx", "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("
").count() == 2, "{html}"); assert!(std::fs::read(dir.join("a.docx")).unwrap().starts_with(b"PK")); + assert!(std::fs::read(dir.join("a.pptx")).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()); } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 3378e3d54..e47a152f8 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -71,7 +71,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ items: &[ 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("Presentation (.pptx)", "presentation", "export.pptx", Ready), item("Image (PNG)", "image", "export.image", Ready), item("Export all images", "image", "export.all_images", Ready), item("HTML web page", "file-symlink", "export.html", Ready), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index aee26adb9..3b2a3f030 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -311,6 +311,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"), c("export.text", "Export to text…", FILE, None, Copying, "type"), c("export.docx", "Export to Word…", FILE, None, Copying, "file-text"), + c("export.pptx", "Export to PowerPoint…", FILE, None, Copying, "presentation"), c("export.html", "Export to HTML…", FILE, None, Copying, "file-symlink"), c("export.rtf", "Export to RTF…", FILE, None, Copying, "file-text"), c("app.preferences", "Preferences…", EDIT, Some(Shortcut::cmd(",")), Nothing, "settings"), diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index fc5a1899c..3c03870f3 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -121,10 +121,11 @@ impl Session { } } -/// Export a PDF ▸ Word, HTML or RTF. +/// Export a PDF ▸ Word, PowerPoint, HTML or RTF. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum OfficeFormat { Docx, + Pptx, Html, Rtf, } @@ -133,6 +134,7 @@ impl OfficeFormat { pub fn extension(self) -> &'static str { match self { OfficeFormat::Docx => "docx", + OfficeFormat::Pptx => "pptx", OfficeFormat::Html => "html", OfficeFormat::Rtf => "rtf", } @@ -141,6 +143,7 @@ impl OfficeFormat { pub fn from_extension(ext: &str) -> Option { match ext.to_ascii_lowercase().as_str() { "docx" => Some(OfficeFormat::Docx), + "pptx" => Some(OfficeFormat::Pptx), "html" | "htm" => Some(OfficeFormat::Html), "rtf" => Some(OfficeFormat::Rtf), _ => None, @@ -204,12 +207,13 @@ impl crate::Document { .collect() } - /// The document as a Word, HTML or RTF file. + /// The document as a Word, PowerPoint, HTML or RTF file. pub fn export_office(&self, format: OfficeFormat) -> Vec { 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 => pdfcraft_export::docx(&pages, &title), + OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title), OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(), OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(), } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8ae54b007..d2f78e616 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -813,7 +813,7 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() { assert!(d.allows_printing() && d.allows_modification() && d.allows_assembly(), "only copying is withheld"); assert_eq!(d.check_copying(), Err(EditError::NotPermitted("copying text and images"))); assert!(d.page_image_file(0, 0).unwrap_err().contains("don't allow copying")); - let extracting = ["edit.snapshot", "export.image", "export.text", "export.docx", "export.html", "export.rtf", "export.all_images"]; + let extracting = ["edit.snapshot", "export.image", "export.text", "export.docx", "export.pptx", "export.html", "export.rtf", "export.all_images"]; let enabled = |s: &Session, id, c| commands::is_enabled(commands::command(c).unwrap(), s, Some(id)); for c in extracting { assert!(!enabled(&s, id, c), "{c} is disabled"); @@ -2549,6 +2549,8 @@ fn exporting_office_files_keeps_images() { 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")); + let pptx = d.export_office(compare::OfficeFormat::Pptx); + assert!(pptx.windows(15).any(|w| w == b"ppt/media/image")); if let Ok(dir) = std::env::var("PDFCRAFT_EXPORT_DIR") { std::fs::write(format!("{dir}/pic.docx"), &docx).unwrap(); } @@ -2612,6 +2614,9 @@ BT /F1 11 Tf 0 g 72 650 Td (Black body text that is long enough to be the body s let d = s.get(id).unwrap(); let html = String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap(); assert!(html.contains("style=\"color:#0D3B2E\">Annual Report"), "{html}"); + let pptx = d.export_office(compare::OfficeFormat::Pptx); + assert!(pptx.starts_with(b"PK")); + assert!(pptx.windows(b"ppt/slides/slide1.xml".len()).any(|w| w == b"ppt/slides/slide1.xml")); let blocks = &d.export_pages()[0].blocks; assert!(blocks.iter().any(|b| b.text == "Annual Report" && b.color == [0.05, 0.23, 0.18]), "{blocks:?}"); } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 54cbac104..52836a05c 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -8,8 +8,10 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod pptx; mod zip; +pub use pptx::pptx; pub use zip::Zip; /// A paragraph of a page. @@ -120,7 +122,7 @@ fn is_cell_like(b: &Block) -> bool { /// Detect tables in a page's blocks from their text layout: columns are left edges shared by /// several short blocks, rows are blocks whose vertical spans overlap. Returns the tables and, /// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). -fn tables(blocks: &[Block]) -> (Vec, Vec) { +pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { let mut consumed = vec![false; blocks.len()]; // Candidate columns: cluster the left edges of cell-like blocks. let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); @@ -326,7 +328,7 @@ 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 { +pub(crate) fn esc(s: &str) -> String { let mut o = String::with_capacity(s.len()); for c in s.chars().filter(|c| allowed(*c)) { match c { @@ -410,13 +412,13 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi } /// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). -fn rgb8(c: [f64; 3]) -> [u8; 3] { +pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { // The value is clamped to 0..=255 before the cast, so it cannot truncate. c.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }) } /// The colour as `RRGGBB`, or `None` for black (the default every format already uses). -fn hex(c: [f64; 3]) -> Option { +pub(crate) fn hex(c: [f64; 3]) -> Option { let [r, g, b] = rgb8(c); (r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}")) } @@ -925,7 +927,7 @@ mod tests { } /// One part of a package written by [`Zip`], inflated. - fn part(zip: &[u8], name: &str) -> String { + pub(crate) 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") { @@ -982,9 +984,13 @@ mod tests { let h = html(&[p.clone()], "Colour"); assert!(h.contains("

Annual Report

"), "{h}"); assert!(h.contains("

"), "{h}"); - let r = rtf(&[p]); + let r = rtf(&[p.clone()]); assert!(r.contains("{\\colortbl;\\red13\\green59\\blue46;\\red0\\green255\\blue0;}"), "{r}"); assert!(r.contains("\\cf1 Annual Report"), "{r}"); + let ppt = pptx(&[p], "Colour"); + let pxml = part(&ppt, "ppt/slides/slide1.xml"); + assert!(pxml.contains(""), "{pxml}"); + assert!(pxml.contains(""), "out-of-range values clamp: {pxml}"); // A table cell keeps its colour too. let mut t = table_page(); t.blocks[0].color = [1.0, 0.0, 0.0]; @@ -1070,12 +1076,20 @@ mod tests { assert!(table_xml.contains(""), "{table_xml}"); assert_eq!(table_xml.matches("").count(), 4); - let r = rtf(&[p]); + let r = rtf(std::slice::from_ref(&p)); assert!(r.contains("\\trowd"), "{r}"); assert_eq!(r.matches("\\row").count(), 4); // Boundaries precede the cells; "Totals" spans two columns, so it ends at the third edge. assert!(r.contains("\\trowd\\trgaph108\\cellx4640\\cellx7840\\cellx9920{"), "{r}"); assert!(r.contains("\\trowd\\trgaph108\\cellx7840{\\intbl\\fs22 Totals}\\cell\\row"), "{r}"); + + let pt = pptx(std::slice::from_ref(&p), "Bill"); + assert!(pt.starts_with(b"PK")); + assert!(pt.windows(b"ppt/slides/slide1.xml".len()).any(|w| w == b"ppt/slides/slide1.xml")); + let slide_xml = part(&pt, "ppt/slides/slide1.xml"); + assert!(slide_xml.contains("")); + assert!(slide_xml.contains("gridSpan=\"2\"")); + assert_eq!(slide_xml.matches(" { + Para(&'a Block), + Table(Table), + Img(&'a Image), +} + +/// Convert points to EMU (English Metric Units: 1 pt = 12700 EMU). +/// +/// Clamps to PowerPoint's allowed slide dimensions: 1 inch (914,400 EMU = 72 pt) +/// to 56 inches (51,206,400 EMU = 4032 pt). +fn emu(pt: f64, default_pt: f64) -> i64 { + if pt.is_finite() && pt > 0.0 { (pt * 12700.0).round().clamp(914400.0, 51206400.0) as i64 } else { (default_pt * 12700.0).round() as i64 } +} + +/// The largest height one item may take on a slide (about 1,100 inches). Text sizes and image +/// rectangles come from the PDF, so a hostile size (say 1e300 pt) must not push the running +/// vertical position past what an `i64` (or a DrawingML coordinate) holds. +const MAX_ITEM_EMU: i64 = 1_000_000_000; + +/// Points to EMU for a height or gap, clamped to `0..=MAX_ITEM_EMU` (NaN reads as 0). +fn height_emu(pt: f64) -> i64 { + let v = pt * 12700.0; + // Clamped to 0..=MAX_ITEM_EMU before the cast, so it cannot truncate or wrap. + if v.is_finite() { + v.round().clamp(0.0, MAX_ITEM_EMU as f64) as i64 + } else if v > 0.0 { + MAX_ITEM_EMU + } else { + 0 + } +} + +/// A formatted DrawingML text run (``). +fn run_drawingml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String { + let mut rpr = String::new(); + let sz = if size.is_finite() { (size * 100.0).round().clamp(100.0, 400000.0) as i64 } else { 1200 }; + rpr.push_str(&format!(" sz=\"{sz}\"")); + if bold { + rpr.push_str(" b=\"1\""); + } + if italic { + rpr.push_str(" i=\"1\""); + } + let fill = if let Some(h) = hex(color) { format!("") } else { String::new() }; + format!("{fill}{}", esc(text)) +} + +/// Generates a DrawingML table inside a `` with explicit cell borders. +fn pptx_table(shape_id: usize, t: &Table, x: i64, y: i64, content_w: i64) -> (String, i64) { + let mut edges = t.cols.clone(); + edges.push(t.rect[2]); + let widths_pt: Vec = edges.windows(2).map(|w| (w[1] - w[0]).max(20.0)).collect(); + let total_pt: f64 = widths_pt.iter().sum(); + let scale = if total_pt > 0.0 { (content_w as f64 / (total_pt * 12700.0)).min(1.0) } else { 1.0 }; + let widths: Vec = widths_pt.iter().map(|w| (w * scale * 12700.0).round().max(127000.0) as i64).collect(); + let table_w: i64 = widths.iter().fold(0i64, |sum, w| sum.saturating_add(*w)); + let row_h: i64 = height_emu(24.0); + let table_h: i64 = i64::try_from(t.rows.len()).unwrap_or(i64::MAX).saturating_mul(row_h).min(MAX_ITEM_EMU); + + let mut grid_cols = String::new(); + for w in &widths { + grid_cols.push_str(&format!("")); + } + + const TC_PR: &str = "\ +\ +\ +\ +\ +"; + + let mut rows_xml = String::new(); + for row in &t.rows { + rows_xml.push_str(&format!("")); + for c in row { + let span = c.span.max(1); + let run = run_drawingml(&c.text, c.size, c.bold, c.italic, c.color); + if span > 1 { + rows_xml.push_str(&format!("{run}{TC_PR}")); + for _ in 1..span { + rows_xml.push_str(&format!("{TC_PR}")); + } + } else { + rows_xml.push_str(&format!("{run}{TC_PR}")); + } + } + let used: usize = row.iter().map(|c| c.span).sum(); + for _ in used..t.cols.len() { + rows_xml.push_str(&format!("{TC_PR}")); + } + rows_xml.push_str(""); + } + + let frame = format!( + "\ +\ +\ +\ +{grid_cols}{rows_xml}\ +" + ); + (frame, table_h) +} + +/// A PowerPoint presentation (.pptx, Office Open XML PresentationML): each page becomes a slide, +/// with the largest text as the title, remaining paragraphs as body text, and images between them. +/// +/// When `pages` is empty, produces a presentation with a single blank slide so the resulting +/// `.pptx` file is a valid OpenXML package that PowerPoint can open without repair warnings. +pub fn pptx(pages: &[Page], title: &str) -> Vec { + let dummy = [Page::default()]; + let pages = if pages.is_empty() { &dummy[..] } else { pages }; + + let (sw, sh) = pages.first().map_or((9144000, 6858000), |p| (emu(p.width, 720.0), emu(p.height, 540.0))); + let mx = (sw / 16).clamp(360000, 914400); + let my = (sh / 16).clamp(360000, 914400); + let content_w = sw - 2 * mx; + + let mut global_media: Vec<(String, &Image)> = Vec::new(); + let mut slides: Vec<(String, String)> = Vec::new(); // (slide_xml, slide_rels) + + for p in pages { + let (tables, consumed) = crate::tables(&p.blocks); + let title_idx = p + .blocks + .iter() + .enumerate() + .filter(|(i, b)| !consumed.get(*i).copied().unwrap_or(false) && !b.text.trim().is_empty()) + .max_by(|(_, a), (_, b)| a.size.total_cmp(&b.size)) + .map(|(i, _)| i); + + let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new(); + for (bi, b) in p.blocks.iter().enumerate() { + if Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false) { + continue; + } + items.push((b.rect[3], b.rect[0], SlideBodyItem::Para(b))); + } + for t in tables { + items.push((t.rect[3], t.rect[0], SlideBodyItem::Table(t))); + } + for im in &p.images { + items.push((im.rect[3], im.rect[0], SlideBodyItem::Img(im))); + } + items.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); + + let mut current_y = my; + let mut shapes = String::new(); + let mut shape_id = 2usize; + let mut slide_media_rels: Vec<(String, usize)> = Vec::new(); + + if let Some(title_block) = title_idx.and_then(|ti| p.blocks.get(ti)) { + let title_h = height_emu((title_block.size * 1.5).max(36.0)); + let title_run = run_drawingml(&title_block.text, title_block.size, title_block.bold, title_block.italic, title_block.color); + shapes.push_str(&format!( + "\ +\ +{title_run}" + )); + shape_id += 1; + current_y = current_y.saturating_add(title_h).saturating_add(height_emu(18.0)); + } + + let mut i = 0; + while let Some((_, _, item)) = items.get(i) { + match item { + SlideBodyItem::Para(_) => { + let mut paras_xml = String::new(); + let mut est_h_pt = 0.0; + while let Some((_, _, SlideBodyItem::Para(b))) = items.get(i) { + let run = run_drawingml(&b.text, b.size, b.bold, b.italic, b.color); + paras_xml.push_str(&format!("{run}")); + let lines = (b.text.len() as f64 / 60.0).ceil().max(1.0); + est_h_pt += (b.size.max(10.0) * 1.3) * lines + 6.0; + i += 1; + } + let box_h = height_emu(est_h_pt); + shapes.push_str(&format!( + "\ +\ +{paras_xml}" + )); + shape_id += 1; + current_y = current_y.saturating_add(box_h).saturating_add(height_emu(12.0)); + } + SlideBodyItem::Img(im) => { + let global_img_idx = global_media.len() + 1; + let img_name = format!("image{global_img_idx}.{}", im.ext); + global_media.push((img_name, *im)); + let r_id = format!("rIdImg{}", slide_media_rels.len() + 1); + slide_media_rels.push((r_id.clone(), global_img_idx)); + + let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0)); + let max_w = content_w as f64 / 12700.0; + let k = (max_w / w).min(1.0); + let cx = height_emu(w * k).min(content_w); + let cy = height_emu(h * k); + let x = mx + (content_w - cx) / 2; + let y = current_y; + + shapes.push_str(&format!( + "\ +\ +" + )); + shape_id += 1; + current_y = current_y.saturating_add(cy).saturating_add(height_emu(12.0)); + i += 1; + } + SlideBodyItem::Table(t) => { + let (table_xml, table_h) = pptx_table(shape_id, t, mx, current_y, content_w); + shapes.push_str(&table_xml); + shape_id += 1; + current_y = current_y.saturating_add(table_h).saturating_add(height_emu(12.0)); + i += 1; + } + } + } + + let slide_xml = format!( + "\n\ +\ +\ +\ +{shapes}" + ); + + let mut slide_rels = String::from( + "\n\ +", + ); + for (r_id, global_idx) in &slide_media_rels { + if let Some((name, _)) = global_media.get(*global_idx - 1) { + slide_rels.push_str(&format!( + "" + )); + } + } + slide_rels.push_str(""); + + slides.push((slide_xml, slide_rels)); + } + + let mut types = String::from( + "\n\ +\ +\ +\ +\ +\ +\ +", + ); + for i in 1..=slides.len() { + types.push_str(&format!( + "" + )); + } + types.push_str(""); + + let rels = "\n\ +\ +"; + + let core = format!( + "\n{}", + esc(title) + ); + + let mut sld_id_lst = String::new(); + let mut pres_rels = String::from( + "\n\ +", + ); + for i in 1..=slides.len() { + let sld_id = 255 + i; + sld_id_lst.push_str(&format!("")); + pres_rels.push_str(&format!( + "" + )); + } + pres_rels.push_str(""); + + let pres = format!( + "\n\ +\ +{sld_id_lst}" + ); + + let slide_master = "\n\ +\ +\ +\ +"; + + let slide_master_rels = "\n\ +\ +"; + + let slide_layout = "\n\ +\ +"; + + let slide_layout_rels = "\n\ +"; + + let theme = "\n\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +"; + + 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("ppt/presentation.xml", pres.as_bytes(), true); + z.add("ppt/_rels/presentation.xml.rels", pres_rels.as_bytes(), true); + z.add("ppt/slideMasters/slideMaster1.xml", slide_master.as_bytes(), true); + z.add("ppt/slideMasters/_rels/slideMaster1.xml.rels", slide_master_rels.as_bytes(), true); + z.add("ppt/slideLayouts/slideLayout1.xml", slide_layout.as_bytes(), true); + z.add("ppt/slideLayouts/_rels/slideLayout1.xml.rels", slide_layout_rels.as_bytes(), true); + z.add("ppt/theme/theme1.xml", theme.as_bytes(), true); + for (i, (s_xml, s_rels)) in slides.iter().enumerate() { + let num = i + 1; + z.add(&format!("ppt/slides/slide{num}.xml"), s_xml.as_bytes(), true); + z.add(&format!("ppt/slides/_rels/slide{num}.xml.rels"), s_rels.as_bytes(), true); + } + for (name, im) in &global_media { + z.add(&format!("ppt/media/{name}"), &im.bytes, false); + } + z.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::part; + + fn sample_page() -> Page { + Page { + width: 720.0, + height: 540.0, + blocks: vec![ + Block { + text: "Q3 Business Update".into(), + rect: [72.0, 460.0, 500.0, 500.0], + size: 28.0, + bold: true, + italic: false, + color: [0.1, 0.2, 0.3], + rotation: 0.0, + }, + Block { + text: "Revenue grew 25% year over year across all regions.".into(), + rect: [72.0, 380.0, 500.0, 410.0], + size: 14.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + }, + Block { + text: "Operational expenses remained flat.".into(), + rect: [72.0, 340.0, 500.0, 370.0], + size: 14.0, + bold: false, + italic: true, + color: [0.0; 3], + rotation: 0.0, + }, + ], + 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, 150.0, 300.0, 300.0], + size: [228.0, 150.0], + rotation: 0.0, + }], + } + } + + #[test] + pub fn pptx_is_a_presentationml_package() { + let p = sample_page(); + let bytes = pptx(&[p], "Executive Deck"); + assert!(bytes.starts_with(b"PK")); + for part_name in [ + "[Content_Types].xml", + "_rels/.rels", + "docProps/core.xml", + "ppt/presentation.xml", + "ppt/_rels/presentation.xml.rels", + "ppt/slideMasters/slideMaster1.xml", + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + "ppt/slideLayouts/slideLayout1.xml", + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + "ppt/theme/theme1.xml", + "ppt/slides/slide1.xml", + "ppt/slides/_rels/slide1.xml.rels", + "ppt/media/image1.png", + ] { + assert!(bytes.windows(part_name.len()).any(|w| w == part_name.as_bytes()), "missing {part_name}"); + } + } + + #[test] + pub fn pptx_slide_has_title_body_and_images_in_order() { + let p = sample_page(); + let bytes = pptx(&[p], "Deck"); + let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); + assert!(slide_xml.contains("name=\"Title 2\"")); + assert!(slide_xml.contains("txBox=\"1\"")); + assert!(slide_xml.contains("Q3 Business Update")); + assert!(slide_xml.contains("name=\"TextBox 3\"")); + assert!(slide_xml.contains("Revenue grew 25%")); + assert!(slide_xml.contains("")); + assert!(slide_xml.contains("")); + assert!(!slide_xml.contains("")); + assert!(slide_xml.contains("i=\"1\"")); + } + + /// Every entry of the package in order: (name, uncompressed bytes), read from the local + /// headers the way an unzipper walks them, so a broken structure fails here. + fn entries(zip: &[u8]) -> Vec<(String, Vec)> { + let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); + let mut out = Vec::new(); + let mut i = 0; + while zip[i..].starts_with(b"PK\x03\x04") { + let (method, size, raw, name_len, extra) = (at(i + 8, 2), at(i + 18, 4), at(i + 22, 4), at(i + 26, 2), at(i + 28, 2)); + let data = i + 30 + name_len + extra; + let name = String::from_utf8(zip[i + 30..i + 30 + name_len].to_vec()).expect("UTF-8 name"); + let body = &zip[data..data + size]; + let bytes = match method { + 0 => body.to_vec(), + 8 => { + let mut v = Vec::new(); + std::io::Read::read_to_end(&mut flate2::read::DeflateDecoder::new(body), &mut v).expect("inflates"); + v + } + m => panic!("{name}: unexpected method {m}"), + }; + assert_eq!(bytes.len(), raw, "{name}: uncompressed size"); + out.push((name, bytes)); + i = data + size; + } + assert!(zip[i..].starts_with(b"PK\x01\x02"), "the central directory follows the entries"); + out + } + + /// Checks that every start tag is closed by the matching end tag (text is escaped, so a + /// `<` always starts markup). Catches a typo such as `…`. + fn assert_tags_balance(name: &str, xml: &str) { + let mut stack: Vec<&str> = Vec::new(); + for chunk in xml.split('<').skip(1) { + let tag = chunk.split('>').next().unwrap_or_default(); + if tag.starts_with('?') { + continue; + } + if let Some(close) = tag.strip_prefix('/') { + assert_eq!(stack.pop(), Some(close.trim()), "{name}: closes the wrong element"); + } else if !tag.ends_with('/') { + stack.push(tag.split_whitespace().next().unwrap_or_default()); + } + } + assert!(stack.is_empty(), "{name}: unclosed {stack:?}"); + } + + #[test] + fn pptx_package_parts_are_complete_and_consistent() { + let cell = |t: &str, x: f64, y: f64| Block { + text: t.into(), + rect: [x, y, x + 100.0, y + 14.0], + size: 12.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + }; + let table_page = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + cell("Metric", 72.0, 400.0), + cell("Value", 200.0, 400.0), + cell("Growth", 72.0, 360.0), + cell("+9%", 200.0, 360.0), + cell("Target", 72.0, 320.0), + cell("100%", 200.0, 320.0), + ], + images: Vec::new(), + }; + let bytes = pptx(&[sample_page(), table_page], "Two"); + let parts = entries(&bytes); + assert_eq!(parts.first().map(|p| p.0.as_str()), Some("[Content_Types].xml"), "content types come first"); + let names: std::collections::HashSet<&str> = parts.iter().map(|p| p.0.as_str()).collect(); + assert_eq!(names.len(), parts.len(), "no duplicate parts"); + for required in [ + "[Content_Types].xml", + "_rels/.rels", + "ppt/presentation.xml", + "ppt/_rels/presentation.xml.rels", + "ppt/slideMasters/slideMaster1.xml", + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + "ppt/slideLayouts/slideLayout1.xml", + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + "ppt/theme/theme1.xml", + "ppt/slides/slide1.xml", + "ppt/slides/_rels/slide1.xml.rels", + "ppt/slides/slide2.xml", + "ppt/slides/_rels/slide2.xml.rels", + "ppt/media/image1.png", + ] { + assert!(names.contains(required), "missing {required}"); + } + let text = |n: &str| String::from_utf8(parts.iter().find(|p| p.0 == n).map(|p| p.1.clone()).unwrap_or_default()).expect("UTF-8 XML"); + for (name, body) in &parts { + if name.ends_with(".xml") || name.ends_with(".rels") { + assert!(body.starts_with(b""), "the second slide carries the table"); + // Text boxes, not placeholders: the blank layout defines none to inherit from. + for slide in ["ppt/slides/slide1.xml", "ppt/slides/slide2.xml"] { + assert!(!text(slide).contains(" = base.split('/').filter(|s| !s.is_empty()).collect(); + for seg in target.split('/') { + if seg == ".." { + path.pop(); + } else { + path.push(seg); + } + } + let resolved = path.join("/"); + assert!(names.contains(resolved.as_str()), "{rels}: target {target} is missing"); + } + } + // The theme's font scheme carries the schema-required latin, ea and cs faces. + let theme = text("ppt/theme/theme1.xml"); + for font in ["majorFont", "minorFont"] { + let inner = theme.split(&format!("")).nth(1).and_then(|r| r.split(&format!("")).next()).unwrap_or_default(); + assert!(inner.contains(""), "the slide is still written"); + assert!(!slide.contains("y=\"-"), "positions never wrap negative"); + assert!(!slide.contains("")); + let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); + assert!(slide_xml.contains("")); + } + + #[test] + fn pptx_slide_dimensions_and_clamping() { + // Known dimensions: 720x540 pt (10x7.5 in) = 9144000x6858000 EMU + let p = Page { width: 720.0, height: 540.0, ..Page::default() }; + let bytes = pptx(&[p], "Known Dimensions"); + let pres_xml = part(&bytes, "ppt/presentation.xml"); + assert!(pres_xml.contains(""), "{pres_xml}"); + + // Tiny page (10x10 pt) clamps to PowerPoint minimum of 1 inch (914400 EMU) + let tiny = Page { width: 10.0, height: 10.0, ..Page::default() }; + let tiny_bytes = pptx(&[tiny], "Tiny"); + let tiny_pres = part(&tiny_bytes, "ppt/presentation.xml"); + assert!(tiny_pres.contains(""), "{tiny_pres}"); + + // Poster page (10000x10000 pt) clamps to PowerPoint maximum of 56 inches (51206400 EMU) + let poster = Page { width: 10_000.0, height: 10_000.0, ..Page::default() }; + let poster_bytes = pptx(&[poster], "Poster"); + let poster_pres = part(&poster_bytes, "ppt/presentation.xml"); + assert!(poster_pres.contains(""), "{poster_pres}"); + + // Non-finite dimensions fall back to default + let nan_page = Page { width: f64::NAN, height: -1.0, ..Page::default() }; + let nan_bytes = pptx(&[nan_page], "NaN"); + let nan_pres = part(&nan_bytes, "ppt/presentation.xml"); + assert!(nan_pres.contains(""), "{nan_pres}"); + } + + #[test] + fn pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title() { + let cell = |t: &str, x: f64, y: f64, w: f64, sz: f64| Block { + text: t.into(), + rect: [x, y, x + w, y + 14.0], + size: sz, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + }; + let p = Page { + width: 720.0, + height: 540.0, + blocks: vec![ + // Heading outside the table (short width, does not span table columns) + Block { + text: "Monthly Summary".into(), + rect: [50.0, 480.0, 150.0, 500.0], + size: 20.0, + bold: true, + italic: false, + color: [0.0; 3], + rotation: 0.0, + }, + // Table where one cell has a very large number (e.g. 36 pt callout in the table) + cell("Metric", 72.0, 400.0, 100.0, 12.0), + cell("Value", 200.0, 400.0, 100.0, 12.0), + cell("Growth", 72.0, 360.0, 100.0, 12.0), + cell("+99%", 200.0, 360.0, 100.0, 36.0), + cell("Target", 72.0, 320.0, 100.0, 12.0), + cell("100%", 200.0, 320.0, 100.0, 12.0), + ], + images: Vec::new(), + }; + let bytes = pptx(&[p], "Table Check"); + let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); + // Title should be the block outside the table ("Monthly Summary"), not the "+99%" cell + assert!(slide_xml.contains("Monthly Summary")); + assert!(slide_xml.contains("name=\"Title 2\"")); + // The table must contain both the cell and explicit borders + assert!(slide_xml.contains("")); + assert!(slide_xml.contains("+99%")); + assert!(slide_xml.contains("")); + } + + #[test] + fn pptx_opens_in_libreoffice() { + let p = sample_page(); + let bytes = pptx(&[p], "LibreOffice Check"); + let dir = std::env::temp_dir().join(format!("pdfcraft-pptx-lo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::create_dir_all(&dir); + let pptx_path = dir.join("test.pptx"); + std::fs::write(&pptx_path, &bytes).unwrap(); + + for cmd in ["libreoffice", "soffice"] { + let status = std::process::Command::new(cmd).args(["--headless", "--convert-to", "pdf", "--outdir"]).arg(&dir).arg(&pptx_path).status(); + if status.is_ok_and(|st| st.success()) { + assert!(dir.join("test.pdf").is_file(), "LibreOffice produced test.pdf"); + break; + } + } + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 30053142e..bbff05732 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -512,6 +512,7 @@ impl PdfCraftApp { "form.export_data" => self.export_data_dialog(false, true), "form.merge_data" => self.merge_data_dialog(), "export.docx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Docx), + "export.pptx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Pptx), "export.html" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Html), "export.rtf" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Rtf), "form.prepare" => { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 71f822487..33a8ea940 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -263,6 +263,7 @@ Optimize PDF… تحسين PDF… Export to text… تصدير إلى نص… Export to Word… تصدير إلى Word… + Export to PowerPoint… تصدير إلى PowerPoint… Export to HTML… تصدير إلى HTML… Export to RTF… تصدير إلى RTF… JavaScript console… وحدة تحكم JavaScript… diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2fb0dc984..6f6024c84 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1778,6 +1778,7 @@ Optimize PDF… Оптимизирай PDF… Export to text… Експортирай като текст… Export to Word… Експортирай в Word… + Export to PowerPoint… Експортирай в PowerPoint… Export to HTML… Експортирай в HTML… Export to RTF… Експортирай в RTF… Export all images… Експортирай всички изображения… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9cee1e741..7cc9cccb3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -71,6 +71,7 @@ Optimize PDF… Optimalizovat PDF… Export to text… Exportovat jako text… Export to Word… Exportovat do formátu Word… + Export to PowerPoint… Exportovat do formátu PowerPoint… Export to HTML… Exportovat do formátu HTML… Export to RTF… Exportovat do formátu RTF… Export all images… Exportovat všechny obrázky… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7eb1638ba..c06816c42 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -236,6 +236,7 @@ Optimize PDF… PDF optimieren… Export to text… Als Text exportieren… Export to Word… Nach Word exportieren… + Export to PowerPoint… Nach PowerPoint exportieren… Export to HTML… Als HTML exportieren… Export to RTF… Als RTF exportieren… JavaScript console… JavaScript-Konsole… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 259f30d01..aa0f08a4f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1626,6 +1626,7 @@ Optimize PDF… Βελτιστοποίηση PDF… Export to text… Εξαγωγή σε κείμενο… Export to Word… Εξαγωγή σε Word… + Export to PowerPoint… Εξαγωγή σε PowerPoint… Export to HTML… Εξαγωγή σε HTML… Export to RTF… Εξαγωγή σε RTF… JavaScript console… Κονσόλα JavaScript… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2a146095c..8e9af6f7d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -259,6 +259,7 @@ Optimize PDF… Optimizar PDF… Export to text… Exportar a texto… Export to Word… Exportar a Word… + Export to PowerPoint… Exportar a PowerPoint… Export to HTML… Exportar a HTML… Export to RTF… Exportar a RTF… JavaScript console… Consola de JavaScript… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8c03f2320..b20793de4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -236,6 +236,7 @@ Optimize PDF… Optimiser le PDF… Export to text… Exporter vers le texte… Export to Word… Exporter vers Word… + Export to PowerPoint… Exporter vers PowerPoint… Export to HTML… Exporter vers HTML… Export to RTF… Exporter vers RTF… JavaScript console… Console JavaScript… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 24fea0b2e..492482068 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -234,6 +234,7 @@ Optimize PDF… PDF optimalizálása… Export to text… Exportálás szövegként… Export to Word… Exportálás Wordbe… + Export to PowerPoint… Exportálás PowerPointba… Export to HTML… Exportálás HTML-be… Export to RTF… Exportálás RTF-be… JavaScript console… JavaScript-konzol… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 340086e3a..8f3699128 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -260,6 +260,7 @@ Optimize PDF… Ottimizza PDF… Export to text… Esporta come testo… Export to Word… Esporta in Word… + Export to PowerPoint… Esporta in PowerPoint… Export to HTML… Esporta in HTML… Export to RTF… Esporta in RTF… JavaScript console… Console JavaScript… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index bc99297ff..7fa2e9e0b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -263,6 +263,7 @@ Optimize PDF… PDF を最適化… Export to text… テキストに書き出す… Export to Word… Word に書き出す… + Export to PowerPoint… PowerPoint に書き出す… Export to HTML… HTML に書き出す… Export to RTF… RTF に書き出す… JavaScript console… JavaScript コンソール… diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index f3bd88cf2..6d9b71cb8 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -224,6 +224,7 @@ Optimize PDF… PDF оңтайландыру… Export to text… Мәтінге экспорттау… Export to Word… Word-ға экспорттау… + Export to PowerPoint… PowerPoint-қа экспорттау… Export to HTML… HTML-ге экспорттау… Export to RTF… RTF-ке экспорттау… JavaScript console… JavaScript консолі… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4ef627dc4..add5ae494 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -259,6 +259,7 @@ Optimize PDF… Optymalizuj PDF… Export to text… Eksportuj do tekstu… Export to Word… Eksportuj do Worda… + Export to PowerPoint… Eksportuj do PowerPointa… Export to HTML… Eksportuj do HTML… Export to RTF… Eksportuj do RTF… JavaScript console… Konsola JavaScript… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 77ec3c9d6..f64fb6620 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1650,6 +1650,7 @@ Optimize PDF… Otimizar PDF… Export to text… Exportar para texto… Export to Word… Exportar para Word… + Export to PowerPoint… Exportar para PowerPoint… Export to HTML… Exportar para HTML… Export to RTF… Exportar para RTF… Export all images… Exportar todas as imagens… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ac228623c..dc8354f28 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -256,6 +256,7 @@ Optimize PDF… Оптимизировать PDF… Export to text… Экспортировать в текст… Export to Word… Экспортировать в Word… + Export to PowerPoint… Экспортировать в PowerPoint… Export to HTML… Экспортировать в HTML… Export to RTF… Экспортировать в RTF… JavaScript console… Консоль JavaScript… diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 31b8b21e6..14680379e 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -730,6 +730,7 @@ Export to RTF… Izvozi v RTF… Export to Text Izvozi v besedilo Export to Word… Izvozi v Word… + Export to PowerPoint… Izvozi v PowerPoint… Export to image… Izvozi v sliko… Export to text… Izvozi v besedilo… Exported 1 image Izvožena je 1 slika diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 26980db78..767bfb497 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -240,6 +240,7 @@ comment menu Edit సవరించు Optimize PDF… PDFను ఆప్టిమైజ్ చేయి… Export to text… టెక్స్ట్‌గా ఎగుమతి చేయి… Export to Word… Wordగా ఎగుమతి చేయి… + Export to PowerPoint… PowerPointగా ఎగుమతి చేయి… Export to HTML… HTMLగా ఎగుమతి చేయి… Export to RTF… RTFగా ఎగుమతి చేయి… JavaScript console… JavaScript కన్సోల్… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5fbc4aaf6..e8a3905e0 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -221,6 +221,7 @@ Optimize PDF… PDF'i eniyile… Export to text… Metne dışa aktar… Export to Word… Word'e dışa aktar… + Export to PowerPoint… PowerPoint'e dışa aktar… Export to HTML… HTML'e dışa aktar… Export to RTF… RTF'ye dışa aktar… JavaScript console… JavaScript konsolu… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 08f4bcc58..de615ee3c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -261,6 +261,7 @@ Optimize PDF… Оптимізувати PDF… Export to text… Експортувати в текст… Export to Word… Експортувати у Word… + Export to PowerPoint… Експортувати у PowerPoint… Export to HTML… Експортувати в HTML… Export to RTF… Експортувати в RTF… JavaScript console… Консоль JavaScript… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 04f338a7c..4deff0d30 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1785,6 +1785,7 @@ Optimize PDF… 优化 PDF… Export to text… 导出为文本… Export to Word… 导出为 Word… + Export to PowerPoint… 导出为 PowerPoint… Export to HTML… 导出为 HTML… Export to RTF… 导出为 RTF… Export all images… 导出所有图像… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4c30de36e..4d8b94105 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1729,6 +1729,7 @@ Optimize PDF… 最佳化 PDF… Export to text… 匯出為文字… Export to Word… 匯出為 Word… + Export to PowerPoint… 匯出為 PowerPoint… Export to HTML… 匯出為 HTML… Export to RTF… 匯出為 RTF… Export all images… 匯出所有圖片… diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 5a9c443c8..f4e7bf855 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -60,6 +60,7 @@ const PICKERS: &[&str] = &[ "ocr.recognize_batch", "form.merge_data", "export.docx", + "export.pptx", "export.html", "export.rtf", "measure.export", diff --git a/crates/ui-egui/tests/compare.rs b/crates/ui-egui/tests/compare.rs index a06d2b076..e2536c6d4 100644 --- a/crates/ui-egui/tests/compare.rs +++ b/crates/ui-egui/tests/compare.rs @@ -98,7 +98,7 @@ fn export_to_word_html_and_rtf() { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("e.pdf", None, doc).unwrap(); - for ext in ["docx", "html", "rtf"] { + for ext in ["docx", "pptx", "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}"))); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 356b1607d..2c7d8ae53 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2010,7 +2010,7 @@ fn copying_out_of_a_document_honours_its_security() { let mut h = open("pw"); assert!(!copy(&mut h), "⌘C copies nothing"); assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), refused); - for command in ["edit.snapshot", "export.image", "export.text", "export.docx", "export.html", "export.rtf", "export.all_images"] { + for command in ["edit.snapshot", "export.image", "export.text", "export.docx", "export.pptx", "export.html", "export.rtf", "export.all_images"] { h.state_mut().toast = None; assert!(!h.state_mut().execute(command), "{command} is refused"); assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), refused, "{command} says why"); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4d9494e52..ab959edc4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -82,7 +82,7 @@ features total). | Word (`.docx`) | yes, layout-preserving | flowing paragraphs and borderless inferred tables; positioned text/artwork only for pages with rotated text or text overlapping an image/watermark, with per-page sizes | **basic** (#773 synthetic invoice regression covered; exact fonts, vector rules and general layout fidelity remain incomplete) | `create.export-docx` partial | | Word 97–2003 (`.doc`) | yes | no | none | | | Excel (`.xlsx`), XML Spreadsheet 2003 | yes | no | none | `create.export-xlsx` planned | -| PowerPoint (`.pptx`) | yes | no | none | `create.export-pptx` planned | +| PowerPoint (`.pptx`) | yes | slides per page: title from largest text, body text, images, tables (no slide overflow reflow / normAutofit; first page sets presentation dimensions) | basic | `create.export-pptx` partial | | RTF | yes | yes | basic | `create.export-rtf` | | HTML (single page, multiple pages) | yes | single file with headings, paragraphs, images, tables | basic | `create.export-html` partial | | Plain text | yes | yes (reading order) | good (word-F1 0.98 vs `pdftotext`) | `create.export-text`, `xtask text-oracle` | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8981af647..62cc8c11a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6967,11 +6967,27 @@ title = "Export to PowerPoint (.pptx)" area = "J" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["export.pptx"] +tools = ["doc_export_office"] +evidence = [ + "crates/export/src/pptx.rs::pptx_is_a_presentationml_package", + "crates/export/src/pptx.rs::pptx_slide_has_title_body_and_images_in_order", + "crates/export/src/pptx.rs::pptx_text_colour_and_formatting", + "crates/export/src/pptx.rs::pptx_empty_pages_produce_valid_slide", + "crates/export/src/pptx.rs::pptx_slide_dimensions_and_clamping", + "crates/export/src/pptx.rs::pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title", + "crates/export/src/pptx.rs::pptx_opens_in_libreoffice", + "crates/export/src/pptx.rs::pptx_package_parts_are_complete_and_consistent", + "crates/export/src/pptx.rs::hostile_sizes_do_not_overflow_the_slide_position", + "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", + "crates/export/src/lib.rs::text_colour_survives_every_format", + "crates/engine/src/tests.rs::exporting_office_files_keeps_images", + "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", + "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", + "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf", +] +notes = "PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text, images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." [[feature]] id = "create.export-png"