Merge main into smooth-scrolling-preference

Conflict: ATTRIBUTION.toml. #1162 changed the 20 translation catalogs
again; they merged cleanly, and their hashes are recomputed from the
merged files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Jeremy YaoandClaude Opus 5.5 committed 2026-10-11 22:34:03 -04:00
commit d4b928f11f
36 files changed
+837 -42

No files matched your search

+20 -20
View File
@@ -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"
+1 -1
View File
@@ -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() }))
+1 -1
View File
@@ -921,7 +921,7 @@ 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: 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.")
+2 -1
View File
@@ -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("<hr>").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());
}
+1 -1
View File
@@ -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),
+1
View File
@@ -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"),
+6 -2
View File
@@ -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<OfficeFormat> {
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<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 => 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(),
}
+6 -1
View File
@@ -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:?}");
}
+21 -7
View File
@@ -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<Table>, Vec<bool>) {
pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
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<String> {
pub(crate) fn hex(c: [f64; 3]) -> Option<String> {
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("<h1 style=\"color:#0D3B2E\">Annual Report</h1>"), "{h}");
assert!(h.contains("<p style=\"color:#00FF00\">"), "{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("<a:srgbClr val=\"0D3B2E\"/>"), "{pxml}");
assert!(pxml.contains("<a:srgbClr val=\"00FF00\"/>"), "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("<w:gridSpan w:val=\"2\"/>"), "{table_xml}");
assert_eq!(table_xml.matches("<w:tr>").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("<a:tbl>"));
assert!(slide_xml.contains("gridSpan=\"2\""));
assert_eq!(slide_xml.matches("<a:tr ").count(), 4);
}
#[test]
+732
View File
@@ -0,0 +1,732 @@
//! PowerPoint presentation (.pptx, Office Open XML PresentationML): slides per page,
//! largest text as slide title, remaining paragraphs as body text, images and tables between them.
use crate::{Block, Image, Page, Table, Zip, esc, hex};
/// Items on a slide in reading order.
enum SlideBodyItem<'a> {
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 (`<a:r>`).
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!("<a:solidFill><a:srgbClr val=\"{h}\"/></a:solidFill>") } else { String::new() };
format!("<a:r><a:rPr{rpr}>{fill}</a:rPr><a:t>{}</a:t></a:r>", esc(text))
}
/// Generates a DrawingML table inside a `<p:graphicFrame>` 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<f64> = 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<i64> = 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!("<a:gridCol w=\"{w}\"/>"));
}
const TC_PR: &str = "<a:tcPr>\
<a:lnL w=\"12700\" cmpd=\"s\"><a:solidFill><a:srgbClr val=\"999999\"/></a:solidFill></a:lnL>\
<a:lnR w=\"12700\" cmpd=\"s\"><a:solidFill><a:srgbClr val=\"999999\"/></a:solidFill></a:lnR>\
<a:lnT w=\"12700\" cmpd=\"s\"><a:solidFill><a:srgbClr val=\"999999\"/></a:solidFill></a:lnT>\
<a:lnB w=\"12700\" cmpd=\"s\"><a:solidFill><a:srgbClr val=\"999999\"/></a:solidFill></a:lnB>\
</a:tcPr>";
let mut rows_xml = String::new();
for row in &t.rows {
rows_xml.push_str(&format!("<a:tr h=\"{row_h}\">"));
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!("<a:tc gridSpan=\"{span}\"><a:txBody><a:bodyPr/><a:lstStyle/><a:p>{run}</a:p></a:txBody>{TC_PR}</a:tc>"));
for _ in 1..span {
rows_xml.push_str(&format!("<a:tc hMerge=\"1\"><a:txBody><a:bodyPr/><a:lstStyle/><a:p/></a:txBody>{TC_PR}</a:tc>"));
}
} else {
rows_xml.push_str(&format!("<a:tc><a:txBody><a:bodyPr/><a:lstStyle/><a:p>{run}</a:p></a:txBody>{TC_PR}</a:tc>"));
}
}
let used: usize = row.iter().map(|c| c.span).sum();
for _ in used..t.cols.len() {
rows_xml.push_str(&format!("<a:tc><a:txBody><a:bodyPr/><a:lstStyle/><a:p/></a:txBody>{TC_PR}</a:tc>"));
}
rows_xml.push_str("</a:tr>");
}
let frame = format!(
"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id=\"{shape_id}\" name=\"Table {shape_id}\"/>\
<p:cNvGraphicFramePr><a:graphicFrameLocks noGrp=\"1\"/></p:cNvGraphicFramePr><p:nvPr/></p:nvGraphicFramePr>\
<p:xfrm><a:off x=\"{x}\" y=\"{y}\"/><a:ext cx=\"{table_w}\" cy=\"{table_h}\"/></p:xfrm>\
<a:graphic><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/table\">\
<a:tbl><a:tblPr/><a:tblGrid>{grid_cols}</a:tblGrid>{rows_xml}</a:tbl>\
</a:graphicData></a:graphic></p:graphicFrame>"
);
(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<u8> {
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!(
"<p:sp><p:nvSpPr><p:cNvPr id=\"{shape_id}\" name=\"Title {shape_id}\"/><p:cNvSpPr txBox=\"1\"/><p:nvPr/></p:nvSpPr>\
<p:spPr><a:xfrm><a:off x=\"{mx}\" y=\"{current_y}\"/><a:ext cx=\"{content_w}\" cy=\"{title_h}\"/></a:xfrm><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom><a:noFill/></p:spPr>\
<p:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"><a:spAutoFit/></a:bodyPr><a:lstStyle/><a:p>{title_run}</a:p></p:txBody></p:sp>"
));
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!("<a:p>{run}</a:p>"));
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!(
"<p:sp><p:nvSpPr><p:cNvPr id=\"{shape_id}\" name=\"TextBox {shape_id}\"/><p:cNvSpPr txBox=\"1\"/><p:nvPr/></p:nvSpPr>\
<p:spPr><a:xfrm><a:off x=\"{mx}\" y=\"{current_y}\"/><a:ext cx=\"{content_w}\" cy=\"{box_h}\"/></a:xfrm><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom><a:noFill/></p:spPr>\
<p:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"><a:spAutoFit/></a:bodyPr><a:lstStyle/>{paras_xml}</p:txBody></p:sp>"
));
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!(
"<p:pic><p:nvPicPr><p:cNvPr id=\"{shape_id}\" name=\"Picture {shape_id}\"/><p:cNvPicPr><a:picLocks noChangeAspect=\"1\"/></p:cNvPicPr><p:nvPr/></p:nvPicPr>\
<p:blipFill><a:blip r:embed=\"{r_id}\"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>\
<p:spPr><a:xfrm><a:off x=\"{x}\" y=\"{y}\"/><a:ext cx=\"{cx}\" cy=\"{cy}\"/></a:xfrm><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></p:spPr></p:pic>"
));
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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
<p:sld xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" \
xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><p:cSld><p:spTree>\
<p:nvGrpSpPr><p:cNvPr id=\"1\" name=\"\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>\
<p:grpSpPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"0\" cy=\"0\"/><a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"0\" cy=\"0\"/></a:xfrm></p:grpSpPr>\
{shapes}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>"
);
let mut slide_rels = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\
<Relationship Id=\"rIdLayout\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout\" Target=\"../slideLayouts/slideLayout1.xml\"/>",
);
for (r_id, global_idx) in &slide_media_rels {
if let Some((name, _)) = global_media.get(*global_idx - 1) {
slide_rels.push_str(&format!(
"<Relationship Id=\"{r_id}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\" Target=\"../media/{name}\"/>"
));
}
}
slide_rels.push_str("</Relationships>");
slides.push((slide_xml, slide_rels));
}
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\"/><Default Extension=\"jpeg\" ContentType=\"image/jpeg\"/>\
<Override PartName=\"/docProps/core.xml\" ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/>\
<Override PartName=\"/ppt/presentation.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml\"/>\
<Override PartName=\"/ppt/slideMasters/slideMaster1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml\"/>\
<Override PartName=\"/ppt/slideLayouts/slideLayout1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml\"/>\
<Override PartName=\"/ppt/theme/theme1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.theme+xml\"/>",
);
for i in 1..=slides.len() {
types.push_str(&format!(
"<Override PartName=\"/ppt/slides/slide{i}.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.slide+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=\"ppt/presentation.xml\"/>\
<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties\" Target=\"docProps/core.xml\"/></Relationships>";
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 sld_id_lst = String::new();
let mut pres_rels = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\
<Relationship Id=\"rIdMaster1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster\" Target=\"slideMasters/slideMaster1.xml\"/>",
);
for i in 1..=slides.len() {
let sld_id = 255 + i;
sld_id_lst.push_str(&format!("<p:sldId id=\"{sld_id}\" r:id=\"rIdSld{i}\"/>"));
pres_rels.push_str(&format!(
"<Relationship Id=\"rIdSld{i}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide\" Target=\"slides/slide{i}.xml\"/>"
));
}
pres_rels.push_str("</Relationships>");
let pres = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
<p:presentation xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" \
xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><p:sldMasterIdLst><p:sldMasterId id=\"2147483648\" r:id=\"rIdMaster1\"/></p:sldMasterIdLst>\
<p:sldIdLst>{sld_id_lst}</p:sldIdLst><p:sldSz cx=\"{sw}\" cy=\"{sh}\"/><p:notesSz cx=\"6858000\" cy=\"9144000\"/></p:presentation>"
);
let slide_master = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
<p:sldMaster xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" \
xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id=\"1\" name=\"\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>\
<p:grpSpPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"0\" cy=\"0\"/><a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"0\" cy=\"0\"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>\
<p:clrMap bg1=\"lt1\" tx1=\"dk1\" bg2=\"lt2\" tx2=\"dk2\" accent1=\"accent1\" accent2=\"accent2\" accent3=\"accent3\" accent4=\"accent4\" accent5=\"accent5\" accent6=\"accent6\" hlink=\"hlink\" folHlink=\"folHlink\"/>\
<p:sldLayoutIdLst><p:sldLayoutId id=\"2147483649\" r:id=\"rIdLayout1\"/></p:sldLayoutIdLst></p:sldMaster>";
let slide_master_rels = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\
<Relationship Id=\"rIdLayout1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout\" Target=\"../slideLayouts/slideLayout1.xml\"/>\
<Relationship Id=\"rIdTheme1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme\" Target=\"../theme/theme1.xml\"/></Relationships>";
let slide_layout = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
<p:sldLayout xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" \
xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" type=\"blank\"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id=\"1\" name=\"\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>\
<p:grpSpPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"0\" cy=\"0\"/><a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"0\" cy=\"0\"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld></p:sldLayout>";
let slide_layout_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/slideMaster\" Target=\"../slideMasters/slideMaster1.xml\"/></Relationships>";
let theme = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
<a:theme xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" name=\"Office Theme\"><a:themeElements>\
<a:clrScheme name=\"Office\"><a:dk1><a:sysClr val=\"windowText\" lastClr=\"000000\"/></a:dk1><a:lt1><a:sysClr val=\"window\" lastClr=\"FFFFFF\"/></a:lt1>\
<a:dk2><a:srgbClr val=\"1F497D\"/></a:dk2><a:lt2><a:srgbClr val=\"EEECE1\"/></a:lt2><a:accent1><a:srgbClr val=\"4F81BD\"/></a:accent1>\
<a:accent2><a:srgbClr val=\"C0504D\"/></a:accent2><a:accent3><a:srgbClr val=\"9BBB59\"/></a:accent3><a:accent4><a:srgbClr val=\"8064A2\"/></a:accent4>\
<a:accent5><a:srgbClr val=\"4BACC6\"/></a:accent5><a:accent6><a:srgbClr val=\"F79646\"/></a:accent6><a:hlink><a:srgbClr val=\"0000FF\"/></a:hlink>\
<a:folHlink><a:srgbClr val=\"800080\"/></a:folHlink></a:clrScheme>\
<a:fontScheme name=\"Office\"><a:majorFont><a:latin typeface=\"Calibri\"/><a:ea typeface=\"\"/><a:cs typeface=\"\"/></a:majorFont><a:minorFont><a:latin typeface=\"Calibri\"/><a:ea typeface=\"\"/><a:cs typeface=\"\"/></a:minorFont></a:fontScheme>\
<a:fmtScheme name=\"Office\"><a:fillStyleLst><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill></a:fillStyleLst>\
<a:lnStyleLst><a:ln w=\"9525\"><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill></a:ln><a:ln w=\"25400\"><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill></a:ln><a:ln w=\"38100\"><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill></a:ln></a:lnStyleLst>\
<a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>\
<a:bgFillStyleLst><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme>\
</a:themeElements></a:theme>";
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("<p:pic>"));
assert!(slide_xml.contains("<a:blip r:embed=\"rIdImg1\"/>"));
assert!(!slide_xml.contains("<p:ph"), "pure text boxes avoid placeholder inheritance repair warnings: {slide_xml}");
let rels_xml = part(&bytes, "ppt/slides/_rels/slide1.xml.rels");
assert!(rels_xml.contains("Target=\"../media/image1.png\""));
assert!(rels_xml.contains("Target=\"../slideLayouts/slideLayout1.xml\""));
}
#[test]
pub fn pptx_text_colour_and_formatting() {
let p = sample_page();
let bytes = pptx(&[p], "Deck");
let slide_xml = part(&bytes, "ppt/slides/slide1.xml");
assert!(slide_xml.contains("sz=\"2800\""));
assert!(slide_xml.contains("b=\"1\""));
assert!(slide_xml.contains("<a:srgbClr val=\"1A334D\"/>"));
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<u8>)> {
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 `<a:lnR>…</a:lnL>`.
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}: </{close}> 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"<?xml"), "{name} is XML");
assert_tags_balance(name, &text(name));
}
}
assert!(text("ppt/slides/slide2.xml").contains("<a:tbl>"), "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("<p:ph"), "{slide} uses no placeholders");
}
// Every override names a part that exists, and every XML part has a content type.
let types = text("[Content_Types].xml");
for chunk in types.split("PartName=\"/").skip(1) {
let part = chunk.split('"').next().unwrap_or_default();
assert!(names.contains(part), "override for missing part {part}");
}
for slide in ["ppt/slides/slide1.xml", "ppt/slides/slide2.xml", "ppt/presentation.xml", "ppt/theme/theme1.xml"] {
assert!(types.contains(&format!("PartName=\"/{slide}\"")), "{slide} has a content type");
}
// Relationship targets resolve to parts in the package.
for (rels, base) in [
("_rels/.rels", ""),
("ppt/_rels/presentation.xml.rels", "ppt/"),
("ppt/slides/_rels/slide1.xml.rels", "ppt/slides/"),
("ppt/slides/_rels/slide2.xml.rels", "ppt/slides/"),
("ppt/slideMasters/_rels/slideMaster1.xml.rels", "ppt/slideMasters/"),
("ppt/slideLayouts/_rels/slideLayout1.xml.rels", "ppt/slideLayouts/"),
] {
for chunk in text(rels).split("Target=\"").skip(1) {
let target = chunk.split('"').next().unwrap_or_default();
let mut path: Vec<&str> = 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!("<a:{font}>")).nth(1).and_then(|r| r.split(&format!("</a:{font}>")).next()).unwrap_or_default();
assert!(inner.contains("<a:latin ") && inner.contains("<a:ea ") && inner.contains("<a:cs "), "{font}: {inner}");
}
assert!(text("ppt/presentation.xml").contains("r:id=\"rIdSld2\""));
}
#[test]
fn hostile_sizes_do_not_overflow_the_slide_position() {
let huge = |text: &str, size: f64, y: f64| Block {
text: text.into(),
rect: [72.0, y, 500.0, y + 10.0],
size,
bold: false,
italic: false,
color: [0.0; 3],
rotation: 0.0,
};
let mut blocks = vec![huge("Title", f64::MAX, 500.0)];
for k in 0..40 {
blocks.push(huge(&"word ".repeat(10_000), 1e300, 400.0 - k as f64));
blocks.push(huge("nan", f64::NAN, 300.0 - k as f64));
blocks.push(huge("inf", f64::INFINITY, 200.0 - k as f64));
}
let images = (0..40)
.map(|k| Image { ext: "png", bytes: vec![0; 4], rect: [0.0, -1e300, 1e-300, 1e300 - k as f64], size: [1.0, 1.0], rotation: 0.0 })
.collect();
let p = Page { width: f64::MAX, height: f64::NAN, blocks, images };
let bytes = pptx(&[p], "Hostile");
let slide = part(&bytes, "ppt/slides/slide1.xml");
assert!(slide.contains("Title") && slide.contains("<p:pic>"), "the slide is still written");
assert!(!slide.contains("y=\"-"), "positions never wrap negative");
assert!(!slide.contains("<p:ph"), "text boxes, not placeholders");
}
#[test]
pub fn pptx_empty_pages_produce_valid_slide() {
let bytes = pptx(&[], "Blank");
assert!(bytes.starts_with(b"PK"));
let pres_xml = part(&bytes, "ppt/presentation.xml");
assert!(pres_xml.contains("<p:sldId id=\"256\" r:id=\"rIdSld1\"/>"));
let slide_xml = part(&bytes, "ppt/slides/slide1.xml");
assert!(slide_xml.contains("<p:spTree>"));
}
#[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("<p:sldSz cx=\"9144000\" cy=\"6858000\"/>"), "{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("<p:sldSz cx=\"914400\" cy=\"914400\"/>"), "{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("<p:sldSz cx=\"51206400\" cy=\"51206400\"/>"), "{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("<p:sldSz cx=\"9144000\" cy=\"6858000\"/>"), "{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("<a:tbl>"));
assert!(slide_xml.contains("+99%"));
assert!(slide_xml.contains("<a:tcPr><a:lnL w=\"12700\" cmpd=\"s\">"));
}
#[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);
}
}
+1
View File
@@ -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" => {
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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… Експортирай всички изображения…
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 479 and column 12.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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 コンソール…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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 консолі…
1 Menu Мәзір
224 Optimize PDF… PDF оңтайландыру…
225 Export to text… Мәтінге экспорттау…
226 Export to Word… Word-ға экспорттау…
227 Export to PowerPoint… PowerPoint-қа экспорттау…
228 Export to HTML… HTML-ге экспорттау…
229 Export to RTF… RTF-ке экспорттау…
230 JavaScript console… JavaScript консолі…
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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 కన్సోల్…
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -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…
1 # Turkish (tr) catalog. Format: see the header of ja.tsv
221
222
223
224
225
226
227
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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… 导出所有图像…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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… 匯出所有圖片…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -60,6 +60,7 @@ const PICKERS: &[&str] = &[
"ocr.recognize_batch",
"form.merge_data",
"export.docx",
"export.pptx",
"export.html",
"export.rtf",
"measure.export",
+1 -1
View File
@@ -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}")));
+1 -1
View File
@@ -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");
+1 -1
View File
@@ -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` |
+21 -5
View File
@@ -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"