From a11bd021cd1f96195d2db6bd626e65e2a7d87fb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Thu, 8 Oct 2026 23:32:53 +0200 Subject: [PATCH 001/298] feat(pptx): export documents as editable PowerPoint presentations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New designcraft-pptx crate (L3) and file.exportPptx command (File › Export PowerPoint…, CLI --export x.pptx). Each page becomes a slide of native objects: shapes with fills, gradients, strokes and effects, pictures cropped like their frames, native tables, and text boxes. Text keeps DesignCraft's composition by default: composed lines end in line breaks, justified spacing becomes character spacing, and leading becomes percentage line spacing (PowerPoint rounds exact spacing to whole points; its 100% line is 1.2 em for every font). Threaded stories split at their frames and wrapped lines get their own boxes. lineBreaks: reflow writes one box per frame in native columns instead. TrueType fonts are embedded as Embedded OpenType when their licences allow. Objects PowerPoint can't represent (placed PDF/EPS, type on a path, some effects) are rasterised through the existing rasterize_where path. Checked in PowerPoint for Mac and LibreOffice: on the sample magazine, word positions match DesignCraft's PDF to within 0.8 pt horizontally and 1.6 pt vertically. --- Cargo.lock | 18 + Cargo.toml | 1 + README.md | 2 +- ROADMAP.md | 1 + apps/designcraft-cli/src/main.rs | 12 +- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/export.rs | 117 ++++- crates/pptx/Cargo.toml | 24 + crates/pptx/src/fonts.rs | 220 +++++++++ crates/pptx/src/lib.rs | 130 ++++++ crates/pptx/src/package.rs | 335 ++++++++++++++ crates/pptx/src/slide.rs | 749 +++++++++++++++++++++++++++++++ crates/pptx/src/table.rs | 208 +++++++++ crates/pptx/src/tests.rs | 271 +++++++++++ crates/pptx/src/text.rs | 625 ++++++++++++++++++++++++++ crates/pptx/src/xml.rs | 75 ++++ crates/ui-egui/src/menus.rs | 3 + xtask/src/layers.rs | 1 + 18 files changed, 2782 insertions(+), 11 deletions(-) create mode 100644 crates/pptx/Cargo.toml create mode 100644 crates/pptx/src/fonts.rs create mode 100644 crates/pptx/src/lib.rs create mode 100644 crates/pptx/src/package.rs create mode 100644 crates/pptx/src/slide.rs create mode 100644 crates/pptx/src/table.rs create mode 100644 crates/pptx/src/tests.rs create mode 100644 crates/pptx/src/text.rs create mode 100644 crates/pptx/src/xml.rs diff --git a/Cargo.lock b/Cargo.lock index 67134162..d7e98731 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,6 +939,7 @@ dependencies = [ "designcraft-idml", "designcraft-images", "designcraft-pdf", + "designcraft-pptx", "designcraft-render", "designcraft-textimport", "designcraft-tools", @@ -1058,6 +1059,23 @@ dependencies = [ "tiff", ] +[[package]] +name = "designcraft-pptx" +version = "0.4.0" +dependencies = [ + "designcraft-color", + "designcraft-compose", + "designcraft-doc", + "designcraft-fonts", + "designcraft-geom", + "designcraft-images", + "image", + "quick-xml 0.38.4", + "skrifa 0.47.0", + "thiserror 2.0.21", + "zip", +] + [[package]] name = "designcraft-render" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index c5918853..3910f957 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ designcraft-render = { path = "crates/render" } designcraft-pdf = { path = "crates/pdf" } designcraft-idml = { path = "crates/idml" } designcraft-epub = { path = "crates/epub" } +designcraft-pptx = { path = "crates/pptx" } designcraft-textimport = { path = "crates/textimport" } designcraft-format = { path = "crates/format" } designcraft-tools = { path = "crates/tools" } diff --git a/README.md b/README.md index 41af2b45..cf73543e 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or Line breaks are identical on screen and in PDF. - **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo snapshots, and cached composition. -- **Open.** A documented native format, IDML import and export, PNG export, and PDF on the roadmap. +- **Open.** A documented native format, IDML import and export, PNG and editable PowerPoint export, and PDF on the roadmap. No subscription, no licence server, no telemetry. - **Agent-native.** Every menu item, tool gesture, panel control and dialog can be driven over a JSON control channel and an **MCP server**, so Claude and other agents can lay out and edit diff --git a/ROADMAP.md b/ROADMAP.md index aa4c1739..07bbe4cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,6 +49,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign. - Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts. - File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB). +- File ▸ Export PowerPoint (`file.exportPptx`, CLI `--export x.pptx`): every page becomes a slide of editable objects: shapes (preset or custom geometry, solid and gradient fills, dashed/compound strokes with arrowheads, shadows, glow, feather, opacity), pictures cropped like their frames (SVG kept as SVG), native tables (merged cells, fills, per-edge borders) and text boxes. By default the text keeps DesignCraft's line breaks, justified spacing and leading; threaded stories split at their frames and lines pushed aside by a wrap get boxes of their own. `lineBreaks: reflow` writes one box per frame in native columns instead. TrueType fonts are embedded where their licences allow, and objects without a PowerPoint equivalent are rasterised. Checked against PowerPoint for Mac and LibreOffice. - Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup). - Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place). - Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions. diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 9e9eba25..ce760cd4 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -1,7 +1,7 @@ //! Headless DesignCraft. //! //! ```text -//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--pdf-options JSON] [--all-pages DIR] +//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub|.pptx] [--pdf-options JSON] [--all-pages DIR] //! designcraft-cli commands [FILTER] # list commands (JSON), optionally only ids/labels/menus containing FILTER //! designcraft-cli describe ID # one command: label, menu, shortcut, parameters //! designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] @@ -209,6 +209,14 @@ fn run(args: &[String]) -> Result<(), String> { } fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> { + if out.to_ascii_lowercase().ends_with(".pptx") { + let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?; + eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]); + for w in r["warnings"].as_array().into_iter().flatten() { + eprintln!("warning: {}", w.as_str().unwrap_or_default()); + } + return Ok(()); + } if out.to_ascii_lowercase().ends_with(".idml") { let r = s.execute("file.exportIdml", &json!({"path": out})).map_err(|e| e.to_string())?; eprintln!("wrote {out} ({} bytes)", r["bytes"]); @@ -332,6 +340,8 @@ fn script(args: &[String]) -> Result<(), String> { exec(b, "file.exportIdml", p)?; } else if lower.ends_with(".epub") { exec(b, "file.exportEpub", p)?; + } else if lower.ends_with(".pptx") { + exec(b, "file.exportPptx", p)?; } else { // PNG / JPEG of the first page. b.call("app.export", p)?; diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ff02d62b..93e7ae3b 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -18,6 +18,7 @@ designcraft-compose = { workspace = true } designcraft-render = { workspace = true } designcraft-format = { workspace = true } designcraft-epub = { workspace = true } +designcraft-pptx = { workspace = true } designcraft-textimport = { workspace = true } designcraft-pdf = { workspace = true } designcraft-tools = { workspace = true } diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index bf986ced..9a928dbc 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -25,6 +25,9 @@ pub fn specs() -> Vec { cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None, "{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes} (no path: {text, bytes})", has_doc, export_html), + cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None, + "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})", + has_doc, export_pptx), cmd!(noundo "file.exportText", "Export Text…", ["File"], None, "{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})", has_story_target, export_text), @@ -66,6 +69,43 @@ fn export_html(s: &mut Session, p: &Value) -> Result { } } +fn export_pptx(s: &mut Session, p: &Value) -> Result { + const ID: &str = "file.exportPptx"; + let st = s.doc()?; + let line_breaks = match str_param(p, "lineBreaks") { + None | Some("keep") => designcraft_pptx::LineBreaks::Keep, + Some("reflow") => designcraft_pptx::LineBreaks::Reflow, + Some(x) => return Err(bad(ID, format!("unknown lineBreaks `{x}` (keep, reflow)"))), + }; + let opts = designcraft_pptx::PptxOptions { + pages: pages_param(ID, p, st.doc.page_count())?, + line_breaks, + title: str_param(p, "title").map(str::to_string), + author: str_param(p, "author").map(str::to_string), + created: None, + embed_fonts: p.get("embedFonts").and_then(Value::as_bool).unwrap_or(true), + }; + // Object Export Options › Rasterize, and what PowerPoint has no objects for (a threaded frame + // stays text: a picture of it would drop the rest of its story). + let auto = p.get("rasterize").and_then(Value::as_bool).unwrap_or(true); + let (raster, rasterized) = rasterize_where(&st.doc, &s.cache, 300.0, |it| { + !threaded(&st.doc, it) && (it.export_options.rasterize || (auto && designcraft_pptx::needs_raster(&st.doc, it))) + }); + let doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); + let mut r = designcraft_pptx::export_pptx(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + if rasterized > 0 { + r.warnings.push(format!("{rasterized} object(s) went in as images")); + } + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})) + } + None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})), + } +} + fn has_story_target(s: &Session) -> std::result::Result<(), String> { super::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into()) } @@ -167,26 +207,31 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { } } -pub(crate) fn options(p: &Value, page_count: usize) -> Result { - const ID: &str = "file.exportPdf"; - - let pages = match p.get("pages") { +/// The `pages` parameter: a range string ("1-3,5"), a list or a number of 1-based positions. +fn pages_param(id: &str, p: &Value, page_count: usize) -> Result>> { + Ok(match p.get("pages") { None | Some(Value::Null) => None, - Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(ID, e.to_string()))?), + Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(id, e.to_string()))?), Some(Value::Array(a)) => { let mut v = Vec::new(); for x in a { - let n = x.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {x}")))?; + let n = x.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(id, format!("bad page {x}")))?; v.push(n as usize - 1); } Some(v) } Some(Value::Number(n)) => { - let n = n.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {n}")))?; + let n = n.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(id, format!("bad page {n}")))?; Some(vec![n as usize - 1]) } - Some(v) => return Err(bad(ID, format!("bad `pages`: {v}"))), - }; + Some(v) => return Err(bad(id, format!("bad `pages`: {v}"))), + }) +} + +pub(crate) fn options(p: &Value, page_count: usize) -> Result { + const ID: &str = "file.exportPdf"; + + let pages = pages_param(ID, p, page_count)?; let spreads = p.get("spreads").and_then(Value::as_bool).unwrap_or(false); let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false); let marks = match p.get("marks") { @@ -899,3 +944,57 @@ mod print_group_tests { assert!(t.contains("/CS/DeviceRGB") && designcraft_pdf::has_rgb_groups(t.as_bytes())); } } + +#[cfg(test)] +mod pptx_tests { + use serde_json::json; + + use crate::Session; + + fn unzip(b64: &str) -> zip::ZipArchive>> { + zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap() + } + + fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); + s + } + + #[test] + fn sample_exports_editable_slides() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let r = s.execute("file.exportPptx", &json!({})).unwrap(); + assert_eq!(r["slides"], 4); + let mut z = unzip(r["base64"].as_str().unwrap()); + // The swatch table is a PowerPoint table; the cover picture is embedded; text is text. + assert!(read(&mut z, "ppt/slides/slide4.xml").contains("")); + assert!(read(&mut z, "ppt/slides/slide1.xml").contains("")); + let text: String = read(&mut z, "ppt/slides/slide2.xml").split("").skip(1).filter_map(|t| t.split("").next()).collect(); + assert!(text.contains("Notes on the Grid"), "{text}"); + assert!(read(&mut z, "ppt/presentation.xml").contains("")); + + let r = s.execute("file.exportPptx", &json!({"pages": "2-3", "embedFonts": false, "lineBreaks": "reflow"})).unwrap(); + assert_eq!(r["slides"], 2); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fonts are not embedded"))); + assert!(s.execute("file.exportPptx", &json!({"lineBreaks": "wrap"})).is_err()); + assert!(s.execute("file.exportPptx", &json!({"pages": [9]})).is_err()); + } + + #[test] + fn rasterize_option_places_a_picture() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap(); + let shapes = |s: &mut Session| { + let r = s.execute("file.exportPptx", &json!({})).unwrap(); + read(&mut unzip(r["base64"].as_str().unwrap()), "ppt/slides/slide1.xml") + }; + assert!(shapes(&mut s).contains("prst=\"ellipse\"")); + s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true})).unwrap(); + let x = shapes(&mut s); + assert!(x.contains("") && !x.contains("prst=\"ellipse\""), "{x}"); + } +} diff --git a/crates/pptx/Cargo.toml b/crates/pptx/Cargo.toml new file mode 100644 index 00000000..fc8fbac7 --- /dev/null +++ b/crates/pptx/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "designcraft-pptx" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DesignCraft PowerPoint (PPTX) export: pages as slides of editable shapes, pictures and text" + +[lints] +workspace = true + +[dependencies] +designcraft-geom = { workspace = true } +designcraft-color = { workspace = true } +designcraft-doc = { workspace = true } +designcraft-fonts = { workspace = true } +designcraft-compose = { workspace = true } +designcraft-images = { workspace = true } +skrifa = { workspace = true } +image = { workspace = true } +thiserror = { workspace = true } +zip = { workspace = true } + +[dev-dependencies] +quick-xml = { workspace = true } diff --git a/crates/pptx/src/fonts.rs b/crates/pptx/src/fonts.rs new file mode 100644 index 00000000..4dceb6ea --- /dev/null +++ b/crates/pptx/src/fonts.rs @@ -0,0 +1,220 @@ +//! Font names as PowerPoint looks them up, and nominal advances to measure its line widths. + +use std::collections::HashMap; + +use designcraft_fonts::FontFace; +use skrifa::MetadataProvider; +use skrifa::string::StringId; + +/// How a run names its face: the legacy (style-linked) family plus bold and italic flags, which +/// is how Office finds a face (`Source Serif 4 Semibold` is its own family; `Bold` is a flag). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RunFont { + pub family: String, + pub bold: bool, + pub italic: bool, +} + +#[derive(Default)] +pub struct Fonts { + names: HashMap, + cmaps: HashMap>, + /// Glyph → the lowest character mapping to it. + reverse: HashMap>, + /// Faces the slides use, by (family, bold, italic), in first-use order. + pub used: Vec<(RunFont, designcraft_fonts::FaceRef)>, +} + +fn english(face: &FontFace, id: StringId) -> Option { + let f = face.skrifa()?; + let mut first = None; + for s in f.localized_strings(id) { + let text: String = s.chars().collect(); + if text.trim().is_empty() { + continue; + } + if s.language().is_none_or(|l| l.starts_with("en")) { + return Some(text); + } + first.get_or_insert(text); + } + first +} + +impl Fonts { + /// The run font for a face a slide uses (recorded for embedding). + pub fn use_face(&mut self, face: designcraft_fonts::FaceRef) -> RunFont { + let rf = self.run_font(face.get()); + if !self.used.iter().any(|(u, _)| *u == rf) { + self.used.push((rf.clone(), face)); + } + rf + } + + /// Embedded OpenType for every family used, and the faces that couldn't be embedded. + pub fn embedded(&self) -> (Vec, Vec) { + let mut out: Vec = Vec::new(); + let mut skipped = Vec::new(); + for (rf, face) in &self.used { + let face = face.get(); + match eot(face) { + Ok(bytes) => { + let slot = usize::from(rf.bold) + 2 * usize::from(rf.italic); + let at = match out.iter().position(|e| e.family == rf.family) { + Some(i) => i, + None => { + out.push(EmbeddedFont { family: rf.family.clone(), ..Default::default() }); + out.len() - 1 + } + }; + if let Some(e) = out.get_mut(at) + && let Some(s) = e.faces.get_mut(slot) + { + *s = Some(bytes); + } + } + Err(why) => skipped.push(format!( + "{} {} ({})", + face.family, + face.style, + if why == NoEmbed::Restricted { "its licence forbids embedding" } else { "not a TrueType font" } + )), + } + } + (out, skipped) + } + + pub fn run_font(&mut self, face: &FontFace) -> RunFont { + self.names + .entry(face.id()) + .or_insert_with(|| { + let family = english(face, StringId::FAMILY_NAME).unwrap_or_else(|| face.family.clone()); + let sub = english(face, StringId::SUBFAMILY_NAME).unwrap_or_else(|| face.style.clone()).to_ascii_lowercase(); + // A named instance of a variable font has no legacy names of its own. + if face.is_variable() { + let style = face.style.to_ascii_lowercase(); + let bold = face.weight >= 650.0; + let regular = matches!(style.as_str(), "regular" | "italic" | "bold" | "bold italic"); + let family = if regular { face.family.clone() } else { format!("{} {}", face.family, face.style.replace(" Italic", "")) }; + return RunFont { family, bold: bold && regular, italic: face.italic }; + } + RunFont { family, bold: sub.contains("bold"), italic: sub.contains("italic") || sub.contains("oblique") } + }) + .clone() + } + + /// Nominal advance of `c` in font units (no kerning or features), as an application without + /// the composer's spacing would set it. + pub fn advance(&mut self, face: &FontFace, c: char) -> Option { + self.cmap(face).get(&c).map(|g| face.advance(*g)) + } + + pub fn glyph(&mut self, face: &FontFace, c: char) -> Option { + self.cmap(face).get(&c).copied() + } + + /// The character a glyph shows (the lowest code point mapping to it). + pub fn char_of(&mut self, face: &FontFace, gid: u32) -> Option { + let map = self.reverse.entry(face.id()).or_insert_with(|| { + let mut m = HashMap::new(); + for (c, g) in face.chars() { + m.entry(g).or_insert(c); + } + m + }); + map.get(&gid).copied() + } + + fn cmap(&mut self, face: &FontFace) -> &HashMap { + self.cmaps.entry(face.id()).or_insert_with(|| face.chars().into_iter().collect()) + } +} + +/// A family embedded in the presentation: its regular, bold, italic and bold italic faces. +#[derive(Clone, Debug, Default)] +pub struct EmbeddedFont { + pub family: String, + pub faces: [Option>; 4], +} + +fn be16(d: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes([*d.get(at)?, *d.get(at + 1)?])) +} + +fn be32(d: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes([*d.get(at)?, *d.get(at + 1)?, *d.get(at + 2)?, *d.get(at + 3)?])) +} + +fn utf16_name(out: &mut Vec, s: &str) { + let units: Vec = s.encode_utf16().collect(); + let bytes = (units.len() * 2).min(u16::MAX as usize & !1); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&(bytes as u16).to_le_bytes()); + for u in units.iter().take(bytes / 2) { + out.extend_from_slice(&u.to_le_bytes()); + } +} + +/// Why a face can't be embedded. +#[derive(Debug, PartialEq, Eq)] +pub enum NoEmbed { + /// The font's licence (OS/2 `fsType`) forbids embedding. + Restricted, + /// PowerPoint embeds TrueType outlines only (not CFF, collections or variable instances). + Format, +} + +/// The face as Embedded OpenType (uncompressed, version 2.1), the form PowerPoint stores +/// embedded fonts in. +pub fn eot(face: &FontFace) -> Result, NoEmbed> { + use skrifa::raw::TableProvider as _; + use skrifa::raw::types::Tag; + let data = face.data(); + if face.index() != 0 || face.is_variable() || data.get(..4) == Some(b"OTTO") || data.get(..4) == Some(b"ttcf") { + return Err(NoEmbed::Format); + } + let f = face.skrifa().ok_or(NoEmbed::Format)?; + if f.glyf().is_err() { + return Err(NoEmbed::Format); + } + let os2 = f.table_data(Tag::new(b"OS/2")).map(|t| t.as_bytes().to_vec()).unwrap_or_default(); + let head = f.table_data(Tag::new(b"head")).map(|t| t.as_bytes().to_vec()).unwrap_or_default(); + let fs_type = be16(&os2, 8).unwrap_or(0); + // Restricted License embedding (bit 1 alone): may not be embedded. + if fs_type & 0x000F == 0x0002 { + return Err(NoEmbed::Restricted); + } + let mut h: Vec = Vec::with_capacity(256); + h.extend_from_slice(&[0; 8]); // EOTSize, FontDataSize (filled in below) + h.extend_from_slice(&0x0002_0001u32.to_le_bytes()); + h.extend_from_slice(&0u32.to_le_bytes()); // Flags: no compression, no obfuscation + h.extend_from_slice(os2.get(32..42).unwrap_or(&[0; 10])); + h.push(1); // DEFAULT_CHARSET + h.push(u8::from(face.italic)); + h.extend_from_slice(&u32::from(be16(&os2, 4).unwrap_or(400)).to_le_bytes()); + h.extend_from_slice(&fs_type.to_le_bytes()); + h.extend_from_slice(&0x504Cu16.to_le_bytes()); + for i in 0..4 { + h.extend_from_slice(&be32(&os2, 42 + 4 * i).unwrap_or(0).to_le_bytes()); + } + let has_codepages = be16(&os2, 0).unwrap_or(0) >= 1; + for i in 0..2 { + h.extend_from_slice(&(if has_codepages { be32(&os2, 78 + 4 * i).unwrap_or(0) } else { 0 }).to_le_bytes()); + } + h.extend_from_slice(&be32(&head, 8).unwrap_or(0).to_le_bytes()); + h.extend_from_slice(&[0; 16]); // Reserved1–4 + let family = english(face, StringId::FAMILY_NAME).unwrap_or_else(|| face.family.clone()); + let style = english(face, StringId::SUBFAMILY_NAME).unwrap_or_else(|| face.style.clone()); + let version = english(face, StringId::VERSION_STRING).unwrap_or_default(); + let full = english(face, StringId::FULL_NAME).unwrap_or_else(|| format!("{family} {style}")); + utf16_name(&mut h, &family); + utf16_name(&mut h, &style); + utf16_name(&mut h, &version); + utf16_name(&mut h, &full); + utf16_name(&mut h, ""); // RootString + let total = h.len().checked_add(data.len()).and_then(|n| u32::try_from(n).ok()).ok_or(NoEmbed::Format)?; + h[0..4].copy_from_slice(&total.to_le_bytes()); + h[4..8].copy_from_slice(&(data.len() as u32).to_le_bytes()); + h.extend_from_slice(data); + Ok(h) +} diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs new file mode 100644 index 00000000..c62bb707 --- /dev/null +++ b/crates/pptx/src/lib.rs @@ -0,0 +1,130 @@ +//! PowerPoint export (File › Export › PowerPoint). +//! +//! Every exported page becomes a slide of native, editable objects: frames become shapes (preset +//! rectangles and ellipses, custom geometry otherwise) with their fills, strokes and effects, +//! placed images become pictures cropped like their frames, and text frames become text boxes. +//! +//! Text keeps DesignCraft's composition by default ([`LineBreaks::Keep`]): each composed line ends +//! in a line break, justified word spacing and tracking become character spacing, and leading +//! becomes percentage line spacing (PowerPoint rounds exact point spacing to whole points; its +//! 100% line is 1.2 em for every font, so a percentage is exact). A threaded story is split at its +//! frames, and the lines a text wrap pushes aside become boxes of their own, since PowerPoint has +//! neither threads nor wraps. [`LineBreaks::Reflow`] instead writes one box per frame with native +//! columns and lets PowerPoint break the lines. +//! +//! Parent-page items are copied onto each slide; layers are flattened in stacking order. What has +//! no PowerPoint equivalent (blend modes, type on a path, some effects) is approximated or +//! dropped and reported in [`ExportReport::warnings`]; [`needs_raster`] names the objects a caller +//! should rasterise first. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod fonts; +mod package; +mod slide; +mod table; +mod text; +mod xml; + +#[cfg(test)] +mod tests; + +use designcraft_compose::Cache; +use designcraft_doc::Document; + +pub use slide::needs_raster; + +/// How text frames become text boxes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum LineBreaks { + /// Keep DesignCraft's line breaks, word spacing and leading (looks the same; edits don't + /// re-break lines). + #[default] + Keep, + /// One box per frame with native columns; PowerPoint breaks the lines (edits reflow; lines, + /// hyphenation and wraps differ). + Reflow, +} + +#[derive(Clone, Debug, Default)] +pub struct PptxOptions { + /// Document pages to export (0-based positions); all when `None`. + pub pages: Option>, + pub line_breaks: LineBreaks, + pub title: Option, + pub author: Option, + /// Creation time (Unix seconds) for the document properties; none when `None`. + pub created: Option, + /// Embed the TrueType fonts the text uses (when their licences allow it), so the slides + /// look the same where they aren't installed. + pub embed_fonts: bool, +} + +/// The exported presentation and what could not be represented exactly. +#[derive(Clone, Debug, Default)] +pub struct ExportReport { + pub bytes: Vec, + pub slides: usize, + pub warnings: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum PptxError { + #[error("the document has no pages to export")] + NoPages, + #[error("page {0} does not exist")] + BadPage(usize), + #[error("could not write the presentation: {0}")] + Write(String), +} + +pub type Result = std::result::Result; + +/// Export `doc` as a PowerPoint presentation. +pub fn export_pptx(doc: &Document, cache: &Cache, opts: &PptxOptions) -> Result { + let count = doc.page_count(); + let pages: Vec = match &opts.pages { + Some(v) => v.clone(), + None => (0..count).collect(), + }; + if pages.is_empty() { + return Err(PptxError::NoPages); + } + if let Some(bad) = pages.iter().find(|p| **p >= count) { + return Err(PptxError::BadPage(bad + 1)); + } + let mut ex = slide::Exporter::new(doc, cache, opts); + let mut slides = Vec::with_capacity(pages.len()); + for abs in &pages { + slides.push(ex.slide(*abs)?); + } + let size = slides.first().map(|s| s.size).ok_or(PptxError::NoPages)?; + if slides.iter().any(|s| (s.size.0 - size.0).abs() > 0.5 || (s.size.1 - size.1).abs() > 0.5) { + ex.warn("pages of different sizes: PowerPoint has one slide size, so every slide uses the first page's"); + } + let fonts = if opts.embed_fonts { + let (fonts, skipped) = ex.fonts.embedded(); + if !skipped.is_empty() { + ex.warn(format!("fonts not embedded: {}", skipped.join(", "))); + } + fonts + } else { + let mut names: Vec = ex.fonts.used.iter().map(|(f, _)| f.family.clone()).collect(); + names.dedup(); + if !names.is_empty() { + ex.warn(format!("fonts are not embedded; the slides need {} installed to look the same", names.join(", "))); + } + Vec::new() + }; + let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); + let bytes = package::write(&package::Package { + size, + slides: &slides, + media: &ex.media, + fonts: &fonts, + title: &title, + author: opts.author.as_deref().unwrap_or(""), + created: opts.created, + })?; + Ok(ExportReport { bytes, slides: slides.len(), warnings: ex.warnings }) +} diff --git a/crates/pptx/src/package.rs b/crates/pptx/src/package.rs new file mode 100644 index 00000000..ed5ad553 --- /dev/null +++ b/crates/pptx/src/package.rs @@ -0,0 +1,335 @@ +//! The PresentationML package: one blank master and layout, a theme, the slides and their media. + +use std::fmt::Write as _; +use std::io::Write as _; + +use crate::fonts::EmbeddedFont; +use crate::xml::{emu_len, esc}; +use crate::{PptxError, Result}; + +pub const NS: &str = r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main""#; +const DECL: &str = "\n"; +const REL_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; +pub const REL_IMAGE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; + +/// A relationship from a slide to another part. +#[derive(Clone, Debug)] +pub struct Rel { + pub id: String, + pub kind: &'static str, + pub target: String, +} + +/// One slide: its shape tree content (everything inside `` after the group +/// properties) and relationships beyond the layout (`rId1`). +#[derive(Clone, Debug, Default)] +pub struct Slide { + /// Page size in points. + pub size: (f64, f64), + pub shapes: String, + pub rels: Vec, + pub name: String, +} + +/// A media part (`ppt/media/`). +#[derive(Clone, Debug)] +pub struct Media { + pub name: String, + pub bytes: Vec, +} + +pub struct Package<'a> { + pub size: (f64, f64), + pub slides: &'a [Slide], + pub media: &'a [Media], + pub fonts: &'a [EmbeddedFont], + pub title: &'a str, + pub author: &'a str, + pub created: Option, +} + +fn rels(list: &[(String, &str, String)]) -> String { + let mut s = format!("{DECL}"); + for (id, kind, target) in list { + let _ = write!(s, "", esc(target)); + } + s.push_str(""); + s +} + +/// PowerPoint accepts slide sizes from 1 inch to 56 inches. +fn slide_extent(pt: f64) -> i64 { + emu_len(pt).clamp(914_400, 51_206_400) +} + +pub fn write(p: &Package) -> Result> { + let mut parts: Vec<(String, Vec)> = Vec::new(); + let mut put = |name: &str, s: String| parts.push((name.to_string(), s.into_bytes())); + let n = p.slides.len(); + + // Content types. + let mut ct = format!( + "{DECL}\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +" + ); + for i in 1..=n { + let _ = write!( + ct, + "" + ); + } + ct.push_str(""); + put("[Content_Types].xml", ct); + + put( + "_rels/.rels", + rels(&[ + ("rId1".into(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "ppt/presentation.xml".into()), + ("rId2".into(), "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", "docProps/core.xml".into()), + ("rId3".into(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", "docProps/app.xml".into()), + ]), + ); + + // Presentation: master rId1, slides rId2.., then the property parts. + let (cx, cy) = (slide_extent(p.size.0), slide_extent(p.size.1)); + let mut ids = String::new(); + let mut prels: Vec<(String, &str, String)> = vec![( + "rId1".into(), + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster", + "slideMasters/slideMaster1.xml".into(), + )]; + for i in 1..=n { + let _ = write!(ids, "", 255 + i, i + 1); + prels.push(( + format!("rId{}", i + 1), + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide", + format!("slides/slide{i}.xml"), + )); + } + for (k, (kind, target)) in + [("presProps", "presProps.xml"), ("viewProps", "viewProps.xml"), ("theme", "theme/theme1.xml"), ("tableStyles", "tableStyles.xml")] + .iter() + .enumerate() + { + prels.push((format!("rId{}", n + 2 + k), rel_kind(kind), target.to_string())); + } + // Embedded fonts: one part per face, listed per family in regular, bold, italic, bold italic. + let mut font_list = String::new(); + let mut font_parts: Vec<(String, &[u8])> = Vec::new(); + for f in p.fonts { + let _ = write!(font_list, "", esc(&f.family)); + for (slot, tag) in ["regular", "bold", "italic", "boldItalic"].iter().enumerate() { + if let Some(Some(bytes)) = f.faces.get(slot) { + let id = format!("rId{}", prels.len() + 1); + let target = format!("fonts/font{}.fntdata", font_parts.len() + 1); + let _ = write!(font_list, ""); + prels.push((id, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font", target.clone())); + font_parts.push((format!("ppt/{target}"), bytes.as_slice())); + } + } + font_list.push_str(""); + } + let (embed_attr, font_list) = if font_parts.is_empty() { + ("", String::new()) + } else { + (" embedTrueTypeFonts=\"1\"", format!("{font_list}")) + }; + put( + "ppt/presentation.xml", + format!( + "{DECL}\ +{ids}{font_list}\ +" + ), + ); + put("ppt/_rels/presentation.xml.rels", rels(&prels)); + + put("ppt/presProps.xml", format!("{DECL}")); + put( + "ppt/viewProps.xml", + format!( + "{DECL}\ +\ +" + ), + ); + put( + "ppt/tableStyles.xml", + format!( + "{DECL}" + ), + ); + + // One blank master and layout. + let tree = ""; + put( + "ppt/slideMasters/slideMaster1.xml", + format!( + "{DECL}{tree}\ +\ +\ +" + ), + ); + put( + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + rels(&[ + ( + "rId1".into(), + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout", + "../slideLayouts/slideLayout1.xml".into(), + ), + ("rId2".into(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", "../theme/theme1.xml".into()), + ]), + ); + put( + "ppt/slideLayouts/slideLayout1.xml", + format!( + "{DECL}{tree}" + ), + ); + put( + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + rels(&[( + "rId1".into(), + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster", + "../slideMasters/slideMaster1.xml".into(), + )]), + ); + put("ppt/theme/theme1.xml", theme()); + + for (i, s) in p.slides.iter().enumerate() { + let name = if s.name.is_empty() { String::new() } else { format!(" name=\"{}\"", esc(&s.name)) }; + put( + &format!("ppt/slides/slide{}.xml", i + 1), + format!( + "{DECL}\ +{}\ +", + s.shapes + ), + ); + let mut list: Vec<(String, &str, String)> = vec![( + "rId1".into(), + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout", + "../slideLayouts/slideLayout1.xml".into(), + )]; + list.extend(s.rels.iter().map(|r| (r.id.clone(), r.kind, r.target.clone()))); + put(&format!("ppt/slides/_rels/slide{}.xml.rels", i + 1), rels(&list)); + } + + put("docProps/core.xml", core(p)); + put( + "docProps/app.xml", + format!( + "{DECL}DesignCraft{n}Custom" + ), + ); + + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let zerr = |e: zip::result::ZipError| PptxError::Write(e.to_string()); + let ioerr = |e: std::io::Error| PptxError::Write(e.to_string()); + for (name, bytes) in &parts { + zw.start_file(name.as_str(), deflate).map_err(zerr)?; + zw.write_all(bytes).map_err(ioerr)?; + } + for (name, bytes) in &font_parts { + zw.start_file(name.as_str(), deflate).map_err(zerr)?; + zw.write_all(bytes).map_err(ioerr)?; + } + for m in p.media { + // Compressed formats gain nothing from deflate. + let opts = if m.name.ends_with(".svg") { deflate } else { stored }; + zw.start_file(format!("ppt/media/{}", m.name), opts).map_err(zerr)?; + zw.write_all(&m.bytes).map_err(ioerr)?; + } + Ok(zw.finish().map_err(zerr)?.into_inner()) +} + +fn rel_kind(k: &str) -> &'static str { + match k { + "presProps" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps", + "viewProps" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/viewProps", + "theme" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", + _ => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles", + } +} + +fn core(p: &Package) -> String { + let mut s = format!( + "{DECL}" + ); + if !p.title.is_empty() { + let _ = write!(s, "{}", esc(p.title)); + } + if !p.author.is_empty() { + let _ = write!(s, "{}", esc(p.author)); + } + if let Some(t) = p.created { + let (y, mo, d, h, mi, se) = civil(t); + let _ = write!(s, "{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z"); + } + s.push_str(""); + s +} + +/// Unix seconds → UTC civil date and time (proleptic Gregorian). +fn civil(t: i64) -> (i64, u32, u32, u32, u32, u32) { + let days = t.div_euclid(86_400); + let secs = t.rem_euclid(86_400); + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = yoe + era * 400 + i64::from(m <= 2); + (y.clamp(0, 9999), m, d, (secs / 3600) as u32, (secs / 60 % 60) as u32, (secs % 60) as u32) +} + +/// A neutral theme: text sets its own fonts and colours, so these only matter for new objects. +fn theme() -> String { + let fill3 = "".repeat(3); + let ln = "".repeat(3); + let fx = "".repeat(3); + format!( + "{DECL}\ +\ +\ +\ +\ +{fill3}{ln}{fx}{fill3}\ +" + ) +} + +#[cfg(test)] +mod tests { + use super::civil; + + #[test] + fn civil_dates() { + assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0)); + assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0)); + assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3)); + } +} diff --git a/crates/pptx/src/slide.rs b/crates/pptx/src/slide.rs new file mode 100644 index 00000000..f4cf4386 --- /dev/null +++ b/crates/pptx/src/slide.rs @@ -0,0 +1,749 @@ +//! A page as a slide: items in stacking order as shapes, pictures, groups and text boxes. + +use std::collections::HashMap; +use std::fmt::Write as _; + +use designcraft_color::swatch::resolve_gradient; +use designcraft_color::{BlendMode, GradientKind}; +use designcraft_compose::Cache; +use designcraft_doc::{Arrowhead, AssetId, Cap, Content, Document, Graphic, Item, Join, Shape, Stroke, StrokeAlign, StrokeType}; +use designcraft_geom::kurbo::{PathEl, Shape as _}; +use designcraft_geom::{Affine, BezPath, Point, Rect, Vec2, corners}; + +use crate::fonts::Fonts; +use crate::package::{Media, REL_IMAGE, Rel, Slide}; +use crate::xml::{angle, emu, emu_len, esc, hex, pct}; +use crate::{PptxError, PptxOptions, Result}; + +/// Objects PowerPoint can't show as editable objects, which a caller should rasterise before +/// exporting: placed PDF and EPS graphics, type on a path, and effects without a DrawingML +/// counterpart. +pub fn needs_raster(doc: &Document, it: &Item) -> bool { + let fx = &it.effects; + let effects = fx.inner_glow.on || fx.bevel.on || fx.satin.on || fx.directional_feather.on || fx.gradient_feather.on; + let placed = |g: &Graphic| { + doc.assets.get(&g.asset).is_some_and(|a| { + let m = a.mime.as_str(); + m == "application/pdf" || m.contains("postscript") || m.contains("illustrator") + }) + }; + let on_path = it.text_frame().is_some_and(|t| t.options.path.is_some()); + effects || on_path || it.graphic().is_some_and(placed) || it.children().iter().any(|c| needs_raster(doc, c)) +} + +/// A DrawingML transform box (slide space, points). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct XfrmBox { + pub rect: Rect, + pub rot: f64, + pub flip_h: bool, + pub flip_v: bool, +} + +impl XfrmBox { + pub fn plain(rect: Rect) -> Self { + XfrmBox { rect, rot: 0.0, flip_h: false, flip_v: false } + } + + pub fn xml(&self) -> String { + let mut a = String::new(); + let r = angle(self.rot); + if r != 0 { + let _ = write!(a, " rot=\"{r}\""); + } + if self.flip_h { + a.push_str(" flipH=\"1\""); + } + if self.flip_v { + a.push_str(" flipV=\"1\""); + } + format!( + "", + emu(self.rect.x0), + emu(self.rect.y0), + emu_len(self.rect.width()), + emu_len(self.rect.height()) + ) + } +} + +/// An affine split into rotation, axis scales and shear: `m = translate · rotate(rot) · +/// [sx, shear·sy; 0, sy]`. +#[derive(Clone, Copy, Debug)] +pub struct Decomposed { + pub rot: f64, + pub sx: f64, + pub sy: f64, + pub shear: f64, +} + +pub fn decompose(m: Affine) -> Decomposed { + let [a, b, c, d, _, _] = m.as_coeffs(); + let sx = a.hypot(b); + let rot = b.atan2(a); + let (cos, sin) = (rot.cos(), rot.sin()); + let c2 = cos * c + sin * d; + let d2 = -sin * c + cos * d; + let shear = if d2.abs() > 1e-12 { c2 / d2 } else { 0.0 }; + Decomposed { rot: rot.to_degrees(), sx, sy: d2, shear } +} + +/// The box `inner` occupies under `m` when `m` has no shear: rotated about its centre, flipped +/// vertically for a negative determinant. `None` for sheared transforms. +pub fn box_of(inner: Rect, m: Affine) -> Option<(XfrmBox, Decomposed)> { + let dm = decompose(m); + if dm.shear.abs() > 1e-6 || !dm.sx.is_finite() || !dm.sy.is_finite() { + return None; + } + let c = m * inner.center(); + let (w, h) = (inner.width() * dm.sx.abs(), inner.height() * dm.sy.abs()); + let rect = Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0); + Some((XfrmBox { rect, rot: dm.rot, flip_h: false, flip_v: dm.sy < 0.0 }, dm)) +} + +/// Shape geometry: a preset, or a custom path in the box's own (unrotated) space. +pub struct Geometry { + pub xfrm: XfrmBox, + pub xml: String, +} + +fn custom_geometry(bp: &BezPath, w: f64, h: f64, filled: bool) -> String { + let (we, he) = (emu_len(w).max(1), emu_len(h).max(1)); + let mut p = format!( + "", + if filled { "" } else { " fill=\"none\"" } + ); + let pt = |q: Point| format!("", emu(q.x), emu(q.y)); + let mut last = Point::ZERO; + for el in bp.elements() { + match *el { + PathEl::MoveTo(a) => { + let _ = write!(p, "{}", pt(a)); + last = a; + } + PathEl::LineTo(a) => { + let _ = write!(p, "{}", pt(a)); + last = a; + } + PathEl::QuadTo(a, b) => { + // As a cubic: PowerPoint's quadratic support is patchy. + let c1 = last + (a - last) * (2.0 / 3.0); + let c2 = b + (a - b) * (2.0 / 3.0); + let _ = write!(p, "{}{}{}", pt(c1), pt(c2), pt(b)); + last = b; + } + PathEl::CurveTo(a, b, c) => { + let _ = write!(p, "{}{}{}", pt(a), pt(b), pt(c)); + last = c; + } + PathEl::ClosePath => p.push_str(""), + } + } + p.push_str(""); + p +} + +fn preset(name: &str) -> String { + format!("") +} + +/// Is `bp` exactly the rectangle `r` (any start corner and direction)? +fn is_rect(bp: &BezPath, r: Rect) -> bool { + let mut pts = Vec::new(); + for el in bp.elements() { + match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(p), + PathEl::ClosePath => {} + _ => return false, + } + } + if pts.len() == 5 && pts.first().zip(pts.last()).is_some_and(|(a, b)| (*a - *b).hypot() < 1e-6) { + pts.pop(); + } + pts.len() == 4 + && pts.iter().all(|p| ((p.x - r.x0).abs() < 1e-6 || (p.x - r.x1).abs() < 1e-6) && ((p.y - r.y0).abs() < 1e-6 || (p.y - r.y1).abs() < 1e-6)) +} + +/// The geometry of `it` (its path, corner options applied) under `m` (inner → slide). +pub fn geometry(it: &Item, m: Affine) -> Geometry { + let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) }; + let closed = it.path.is_closed(); + let inner = bp.bounding_box(); + if let Some((xfrm, dm)) = box_of(inner, m) { + if it.corners.is_none() && is_rect(&bp, inner) { + return Geometry { xfrm, xml: preset("rect") }; + } + if it.shape == Shape::Oval && it.corners.is_none() && closed { + return Geometry { xfrm, xml: preset("ellipse") }; + } + // The path in the box's own space: scaled (not flipped: the box flips), from its corner. + let (kx, ky) = (dm.sx.abs(), dm.sy.abs()); + let mut local = bp.clone(); + local.apply_affine(Affine::scale_non_uniform(kx, ky) * Affine::translate(-inner.origin().to_vec2())); + return Geometry { xfrm, xml: custom_geometry(&local, xfrm.rect.width(), xfrm.rect.height(), closed) }; + } + // Sheared: the transformed path in slide space. + let mut pb = bp; + pb.apply_affine(m); + let b = pb.bounding_box(); + pb.apply_affine(Affine::translate(-b.origin().to_vec2())); + Geometry { xfrm: XfrmBox::plain(b), xml: custom_geometry(&pb, b.width(), b.height(), closed) } +} + +pub struct Exporter<'a> { + pub doc: &'a Document, + pub cache: &'a Cache, + pub opts: &'a PptxOptions, + pub warnings: Vec, + pub media: Vec, + /// Asset → (picture media name, SVG media name). + media_of: HashMap)>>, + pub fonts: Fonts, + // Per slide. + pub out: String, + next_id: u32, + rels: Vec, + rel_of: HashMap, + /// Spread space → slide space. + pub to_slide: Affine, + /// The page's bleed area in spread space (items outside it are left out). + clip: Rect, + pub page_name: Option, +} + +impl<'a> Exporter<'a> { + pub fn new(doc: &'a Document, cache: &'a Cache, opts: &'a PptxOptions) -> Self { + Exporter { + doc, + cache, + opts, + warnings: Vec::new(), + media: Vec::new(), + media_of: HashMap::new(), + fonts: Fonts::default(), + out: String::new(), + next_id: 2, + rels: Vec::new(), + rel_of: HashMap::new(), + to_slide: Affine::IDENTITY, + clip: Rect::ZERO, + page_name: None, + } + } + + pub fn warn(&mut self, w: impl Into) { + let w = w.into(); + if !self.warnings.contains(&w) { + self.warnings.push(w); + } + } + + pub fn id(&mut self) -> u32 { + let i = self.next_id; + self.next_id = self.next_id.saturating_add(1); + i + } + + pub fn slide(&mut self, abs: usize) -> Result { + let doc = self.doc; + let (si, pi) = doc.page_loc(abs).ok_or(PptxError::BadPage(abs + 1))?; + let sp = doc.spreads.get(si).ok_or(PptxError::BadPage(abs + 1))?; + let page = sp.pages.get(pi).ok_or(PptxError::BadPage(abs + 1))?; + let bounds = page.bounds(); + self.out.clear(); + self.next_id = 2; + self.rels.clear(); + self.rel_of.clear(); + self.to_slide = Affine::translate(-bounds.origin().to_vec2()); + let b = doc.settings.bleed; + self.clip = Rect::new(bounds.x0 - b[2].max(b[3]), bounds.y0 - b[0], bounds.x1 + b[2].max(b[3]), bounds.y1 + b[1]); + let page_name = doc.page_name(abs); + for layer in doc.layers.iter().rev() { + if !layer.visible || !layer.printable { + continue; + } + if page.show_parent_items + && let Some((ppi, ppage)) = doc.parent_page_for(abs) + && let Some(parent) = doc.parents.get(ppi) + && let Some(pp) = parent.pages.get(ppage) + { + let dx = page.x - pp.x; + for it in &parent.items { + if it.layer != layer.id || page.overridden.contains(&it.id) { + continue; + } + if parent.pages.len() > 1 && parent.page_at_x(it.bounds().center().x) != Some(ppage) { + continue; + } + // Parent items compose per page (page numbers, running heads). + self.page_name = Some(page_name.clone()); + self.item(it, Affine::translate((dx, 0.0)), 1.0); + self.page_name = None; + } + } + for it in &sp.items { + if it.layer == layer.id { + self.item(it, Affine::IDENTITY, 1.0); + } + } + } + Ok(Slide { + size: (bounds.width(), bounds.height()), + shapes: std::mem::take(&mut self.out), + rels: std::mem::take(&mut self.rels), + name: page_name, + }) + } + + fn rel(&mut self, target: String) -> String { + if let Some(r) = self.rel_of.get(&target) { + return r.clone(); + } + let id = format!("rId{}", self.rels.len() + 2); + self.rels.push(Rel { id: id.clone(), kind: REL_IMAGE, target: target.clone() }); + self.rel_of.insert(target, id.clone()); + id + } + + /// One item under `parent` (inner-of-parent → spread), with an inherited opacity. + pub fn item(&mut self, it: &Item, parent: Affine, opacity: f32) { + if it.hidden || it.nonprinting { + return; + } + let reach = it.stroke.extent() + + 2.0 + + if it.effects.drop_shadow.on { it.effects.drop_shadow.distance.abs() + it.effects.drop_shadow.size.abs() } else { 0.0 }; + let vb = parent.transform_rect_bbox(it.bounds()).inflate(reach, reach); + if vb.x1 < self.clip.x0 || vb.x0 > self.clip.x1 || vb.y1 < self.clip.y0 || vb.y0 > self.clip.y1 { + return; + } + if it.blend != BlendMode::Normal { + self.warn("blend modes have no PowerPoint equivalent; those objects are drawn normally"); + } + if needs_raster(self.doc, it) && !it.is_group() { + self.warn("some effects (inner glow, bevel and emboss, satin, gradient and directional feather), type on a path and placed PDF/EPS graphics can't be exported as PowerPoint objects; rasterise them (Object Export Options) or they are approximated"); + } + let xf = parent * it.xf; + let opacity = opacity * it.opacity.clamp(0.0, 1.0); + if it.has_nested_items() { + // Paste Into: the frame, then its contents (PowerPoint can't clip a group to a shape). + self.shape(it, xf, opacity, None); + let fb = xf.transform_rect_bbox(it.inner_bounds()); + for c in it.children() { + let cb = xf.transform_rect_bbox(c.bounds()); + if cb.x0 < fb.x0 - 0.5 || cb.y0 < fb.y0 - 0.5 || cb.x1 > fb.x1 + 0.5 || cb.y1 > fb.y1 + 0.5 { + self.warn("contents pasted into a frame are not clipped to it"); + } + self.item(c, xf, opacity); + } + return; + } + if !it.children().is_empty() { + self.group(it, xf, opacity); + return; + } + match &it.content { + Content::Graphic(g) => self.picture(it, g, xf, opacity), + Content::Text(t) => { + let t = t.clone(); + crate::text::frame(self, it, &t, xf, opacity); + } + _ => self.shape(it, xf, opacity, None), + } + } + + fn group(&mut self, it: &Item, xf: Affine, opacity: f32) { + let mut body = std::mem::take(&mut self.out); + let id = self.id(); + for c in it.shown_children() { + self.item(c, xf, opacity); + } + let inner = std::mem::take(&mut self.out); + if inner.is_empty() { + self.out = body; + return; + } + // Children are in slide space: the group's child space is the slide's. + let inner_bounds = it.shown_children().map(|c| c.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + let b = (self.to_slide * xf).transform_rect_bbox(inner_bounds); + let (x, y, w, h) = (emu(b.x0), emu(b.y0), emu_len(b.width()), emu_len(b.height())); + let _ = write!( + body, + "{inner}", + esc(&name_of(it, id)), + descr(it) + ); + self.out = body; + } + + /// A shape with the item's geometry, fill, stroke and effects; `text` is a ``. + pub fn shape(&mut self, it: &Item, xf: Affine, opacity: f32, text: Option<&str>) { + let m = self.to_slide * xf; + let g = geometry(it, m); + let fill = self.fill(&it.fill, it.path.is_closed(), opacity); + let line = self.line(&it.stroke, opacity); + let fx = self.effects(it, opacity); + if text.is_none() && fill == "" && line.contains("") && fx.is_empty() { + return; + } + let id = self.id(); + let txbox = if text.is_some() && it.fill.is_none() && it.stroke.is_none() { " txBox=\"1\"" } else { "" }; + let _ = write!( + self.out, + "{}{}{fill}{line}{fx}{}", + esc(&name_of(it, id)), + descr(it), + g.xfrm.xml(), + g.xml, + text.unwrap_or("") + ); + } + + /// An sRGB colour element for a swatch and tint, `None` for [None] and unknown swatches. + pub fn color(&self, swatch: &str, tint: f32, alpha: f32) -> Option { + if let Some(g) = resolve_gradient(&self.doc.swatches, swatch) { + // A gradient used where only a colour fits (text): its first stop. + let (_, c, a) = g.expanded_stops().into_iter().next()?; + return Some(srgb(c.to_rgb(), a * alpha)); + } + let c = self.doc.resolve_color(swatch, tint)?; + Some(srgb(c.to_rgb(), alpha)) + } + + pub fn fill(&mut self, f: &designcraft_doc::Fill, closed: bool, opacity: f32) -> String { + if f.is_none() || !closed { + return "".into(); + } + if let Some(g) = resolve_gradient(&self.doc.swatches, &f.swatch) { + let mut gs = String::new(); + for (o, c, a) in g.expanded_stops() { + let _ = write!(gs, "{}", pct(o.clamp(0.0, 1.0) as f64), srgb(c.to_rgb(), a * opacity)); + } + if gs.is_empty() { + return "".into(); + } + if f.gradient_vector.is_some() { + self.warn("gradients set with the Gradient tool use their angle only; PowerPoint stretches them over the whole shape"); + } + let shade = match g.kind { + GradientKind::Radial => { + "".to_string() + } + _ => { + let deg = match f.gradient_vector { + Some([x0, y0, x1, y1]) => (y1 - y0).atan2(x1 - x0).to_degrees(), + None => -f.gradient_angle.unwrap_or(0.0), + }; + format!("", angle(deg)) + } + }; + return format!("{gs}{shade}"); + } + match self.color(&f.swatch, f.tint, opacity) { + Some(c) => format!("{c}"), + None => "".into(), + } + } + + pub fn line(&mut self, s: &Stroke, opacity: f32) -> String { + if s.is_none() || s.weight <= 0.0 { + return "".into(); + } + let Some(color) = self.color(&s.swatch, s.tint, opacity) else { return "".into() }; + let cap = match s.cap { + Cap::Butt => "flat", + Cap::Round => "rnd", + Cap::Projecting => "sq", + }; + let cmpd = match s.kind { + StrokeType::ThickThin => "thickThin", + StrokeType::ThinThick => "thinThick", + StrokeType::ThinThin | StrokeType::ThickThick => "dbl", + StrokeType::ThinThickThin | StrokeType::ThickThinThick => "tri", + _ => "sng", + }; + if matches!(s.kind, StrokeType::Wavy | StrokeType::Hashed | StrokeType::Stripes { .. } | StrokeType::Style { .. }) { + self.warn("wavy, hashed, striped and custom stroke styles are exported as solid lines"); + } + let algn = match s.align { + StrokeAlign::Inside => " algn=\"in\"", + StrokeAlign::Outside => { + self.warn("strokes aligned outside are centred on the path (PowerPoint has centre and inside only)"); + "" + } + StrokeAlign::Center => "", + }; + let w = s.weight; + let dash = match &s.kind { + StrokeType::Dashed { pattern } if pattern.iter().any(|v| *v > 0.0) => { + let mut v: Vec = pattern.iter().map(|x| x.max(0.0)).collect(); + if v.len() % 2 == 1 { + v.extend(v.clone()); + } + let mut d = String::from(""); + for pair in v.chunks(2) { + if let [a, b] = pair { + let _ = write!(d, "", pct(a / w), pct(b / w)); + } + } + d.push_str(""); + d + } + StrokeType::Dotted => "".into(), + _ => "".into(), + }; + let join = match s.join { + Join::Miter => format!("", pct(s.miter_limit.max(1.0))), + Join::Round => "".into(), + Join::Bevel => "".into(), + }; + let ends = |tag: &str, a: Arrowhead, me: &mut Self| -> String { + let kind = match a { + Arrowhead::None => return String::new(), + Arrowhead::Simple | Arrowhead::SimpleWide => "arrow", + Arrowhead::Triangle | Arrowhead::TriangleWide | Arrowhead::Curved => "triangle", + Arrowhead::Barbed => "stealth", + Arrowhead::Circle | Arrowhead::CircleSolid => "oval", + Arrowhead::Square | Arrowhead::SquareSolid => "diamond", + Arrowhead::Bar => { + me.warn("bar arrowheads have no PowerPoint equivalent and are left out"); + return String::new(); + } + }; + // PowerPoint's large heads come closest to DesignCraft's at the same weight. + format!("") + }; + let head = ends("headEnd", s.start, self); + let tail = ends("tailEnd", s.end, self); + format!("{color}{dash}{join}{head}{tail}", emu_len(w)) + } + + /// The item's effects that DrawingML has (in schema order). + pub fn effects(&mut self, it: &Item, opacity: f32) -> String { + let fx = &it.effects; + let mut s = String::new(); + if fx.outer_glow.on + && let Some(c) = self.color(&fx.outer_glow.color, 1.0, fx.outer_glow.opacity * opacity) + { + let _ = write!(s, "{c}", emu_len(fx.outer_glow.size)); + } + if fx.inner_shadow.on { + let sh = &fx.inner_shadow; + if let Some(c) = self.color(&sh.color, 1.0, sh.opacity * opacity) { + let a = self.doc.light_angle(sh.angle, sh.global_light); + let _ = write!( + s, + "{c}", + emu_len(sh.size), + emu_len(sh.distance.abs()), + shadow_dir(a) + ); + } + } + if fx.drop_shadow.on { + let sh = &fx.drop_shadow; + if let Some(c) = self.color(&sh.color, 1.0, sh.opacity * opacity) { + let a = self.doc.light_angle(sh.angle, sh.global_light); + let _ = write!( + s, + "{c}", + emu_len(sh.size), + emu_len(sh.distance.abs()), + shadow_dir(a) + ); + } + } + if fx.feather > 0.0 { + let _ = write!(s, "", emu_len(fx.feather)); + } + if s.is_empty() { s } else { format!("{s}") } + } + + /// The media for a placed graphic: PNG, JPEG and GIF as they are, SVG with a PNG fallback, + /// other rasters converted to PNG. + fn media_for(&mut self, asset: AssetId) -> Option<(String, Option)> { + if let Some(m) = self.media_of.get(&asset) { + return m.clone(); + } + let r = self.convert(asset); + self.media_of.insert(asset, r.clone()); + r + } + + fn convert(&mut self, asset: AssetId) -> Option<(String, Option)> { + let a = self.doc.assets.get(&asset)?.clone(); + let data: &[u8] = &a.data; + if data.is_empty() { + self.warn(format!("`{}` is missing (linked file not found); left out", a.name)); + return None; + } + let n = self.media.len() + 1; + let mime = designcraft_images::mime(data); + let ext = match mime { + "image/png" => Some("png"), + "image/jpeg" => Some("jpeg"), + "image/gif" => Some("gif"), + _ => None, + }; + if let Some(ext) = ext { + let name = format!("image{n}.{ext}"); + self.media.push(Media { name: name.clone(), bytes: data.to_vec() }); + return Some((name, None)); + } + if mime == "image/svg+xml" { + let png = designcraft_images::render_svg(data, 2048).and_then(|(px, w, h)| encode_png(unpremultiply(px), w, h)); + let Some(png) = png else { + self.warn(format!("`{}` could not be read; left out", a.name)); + return None; + }; + let name = format!("image{n}.png"); + let svg = format!("image{}.svg", n + 1); + self.media.push(Media { name: name.clone(), bytes: png }); + self.media.push(Media { name: svg.clone(), bytes: data.to_vec() }); + return Some((name, Some(svg))); + } + if mime == "application/pdf" || mime == "application/octet-stream" { + self.warn(format!( + "`{}` (PDF, EPS or an unknown format) can't be placed in PowerPoint; rasterise it (Object Export Options) to include it", + a.name + )); + return None; + } + let Some(img) = designcraft_images::decode_rgba(data) else { + self.warn(format!("`{}` could not be read; left out", a.name)); + return None; + }; + let (w, h) = img.dimensions(); + let png = encode_png(img.into_raw(), w, h)?; + let name = format!("image{n}.png"); + self.media.push(Media { name: name.clone(), bytes: png }); + Some((name, None)) + } + + fn picture(&mut self, it: &Item, g: &Graphic, xf: Affine, opacity: f32) { + let g = if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() }; + let Some((name, svg)) = self.media_for(g.asset) else { + self.shape(it, xf, opacity, None); + return; + }; + let m = self.to_slide * xf; + let [a, b, c, d, _, _] = g.xf.as_coeffs(); + let frame = it.inner_bounds(); + let img = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1)); + let axis = b.abs() < 1e-9 && c.abs() < 1e-9 && a > 0.0 && d > 0.0; + let (geo, crop) = if axis && img.width() > 0.0 && img.height() > 0.0 { + // The frame's geometry with the image cropped (or padded) to the frame's box. + let crop = format!( + "", + pct((frame.x0 - img.x0) / img.width()), + pct((frame.y0 - img.y0) / img.height()), + pct((img.x1 - frame.x1) / img.width()), + pct((img.y1 - frame.y1) / img.height()) + ); + (geometry(it, m), crop) + } else { + // Rotated, skewed or flipped inside its frame: the image's own box, uncropped, and + // the frame drawn on its own. + self.warn("images rotated, skewed or flipped inside their frames are not cropped to the frame"); + self.shape(it, xf, opacity, None); + let gi = Rect::new(0.0, 0.0, g.size.0, g.size.1); + let gm = m * g.xf; + let xfrm = box_of(gi, gm).map(|(x, _)| x).unwrap_or_else(|| XfrmBox::plain(gm.transform_rect_bbox(gi))); + (Geometry { xfrm, xml: preset("rect") }, String::new()) + }; + let rid = self.rel(format!("../media/{name}")); + let svg_ext = match svg { + Some(s) => { + let sid = self.rel(format!("../media/{s}")); + format!( + "" + ) + } + None => String::new(), + }; + let alpha = if opacity < 0.999 { format!("", pct(opacity as f64)) } else { String::new() }; + let fill = self.fill(&it.fill, it.path.is_closed(), opacity); + let line = if axis { self.line(&it.stroke, opacity) } else { "".into() }; + let fx = self.effects(it, opacity); + let id = self.id(); + let _ = write!( + self.out, + "\ +{alpha}{svg_ext}{crop}\ +{}{}{fill}{line}{fx}", + esc(&name_of(it, id)), + descr(it), + geo.xfrm.xml(), + geo.xml + ); + } + + /// Paragraph shading and rules, table cell fills: a plain filled rectangle in slide space. + pub fn rect_shape(&mut self, xfrm: XfrmBox, fill: &str, name: &str) { + let id = self.id(); + let _ = write!( + self.out, + "{}{}{fill}", + esc(name), + xfrm.xml(), + preset("rect") + ); + } +} + +/// DrawingML shadow direction (clockwise from +x, y down) for a light angle (degrees, +/// counter-clockwise; the shadow falls away from the light). +fn shadow_dir(light: f64) -> i64 { + let a = light.to_radians(); + let off = Vec2::new(-a.cos(), a.sin()); + angle(off.y.atan2(off.x).to_degrees()) +} + +pub fn srgb(rgb: [f32; 3], alpha: f32) -> String { + let a = alpha.clamp(0.0, 1.0); + if a >= 0.999 { + format!("", hex(rgb)) + } else { + format!("", hex(rgb), pct(a as f64)) + } +} + +fn name_of(it: &Item, id: u32) -> String { + if !it.name.is_empty() { + return it.name.clone(); + } + let kind = match (&it.content, it.shape) { + (Content::Group { .. }, _) => "Group", + (Content::Text(_), _) => "Text Frame", + (Content::Graphic(_), _) => "Picture", + (_, Shape::Oval) => "Ellipse", + (_, Shape::Polygon) => "Polygon", + (_, Shape::GraphicLine) => "Line", + (_, Shape::Path) => "Path", + _ => "Rectangle", + }; + format!("{kind} {id}") +} + +fn descr(it: &Item) -> String { + if it.alt_text.is_empty() { String::new() } else { format!(" descr=\"{}\"", esc(&it.alt_text)) } +} + +fn unpremultiply(mut px: Vec) -> Vec { + for p in px.as_chunks_mut::<4>().0 { + let a = p[3] as u32; + if a > 0 && a < 255 { + for c in &mut p[..3] { + *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + px +} + +fn encode_png(rgba: Vec, w: u32, h: u32) -> Option> { + let img = image::RgbaImage::from_raw(w, h, rgba)?; + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).ok()?; + Some(out.into_inner()) +} diff --git a/crates/pptx/src/table.rs b/crates/pptx/src/table.rs new file mode 100644 index 00000000..0c8bd27d --- /dev/null +++ b/crates/pptx/src/table.rs @@ -0,0 +1,208 @@ +//! Table fragments as native PowerPoint tables: the grid from the cells' edges, merged cells as +//! spans, fills, edges as cell borders, and each cell's composed text. + +use std::fmt::Write as _; + +use designcraft_compose::{FrameText, PlacedCell, StrokeSeg, TableFrag}; +use designcraft_doc::StrokeType; +use designcraft_geom::{Affine, Rect}; + +use crate::slide::{Exporter, box_of}; +use crate::text::{paragraphs, seg_rect, segments}; +use crate::xml::{emu, emu_len}; + +/// Sorted distinct values (within 0.25 pt). +fn edges(mut v: Vec) -> Vec { + v.retain(|x| x.is_finite()); + v.sort_by(f64::total_cmp); + v.dedup_by(|a, b| (*a - *b).abs() < 0.25); + v +} + +fn index(edges: &[f64], x: f64) -> usize { + edges.iter().enumerate().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(0, |(i, _)| i) +} + +pub fn tables(ex: &mut Exporter, ft: &FrameText, m: Affine, k: f64, opacity: f32) { + for t in &ft.tables { + table(ex, t, m, k, opacity); + } +} + +/// The stroke along one side of a cell (`a`–`b`), if the segments on that line cover most of it +/// (a merged cell's side is drawn in pieces, one per grid cell). +fn edge_stroke(strokes: &[StrokeSeg], a: (f64, f64), b: (f64, f64)) -> Option<&StrokeSeg> { + let vertical = (a.0 - b.0).abs() < 0.25; + let (lo, hi) = if vertical { (a.1, b.1) } else { (a.0, b.0) }; + let mut covered = 0.0; + let mut first = None; + for s in strokes { + let (on, s0, s1) = if vertical { + ((s.a.x - a.0).abs() < 0.75 && (s.b.x - a.0).abs() < 0.75, s.a.y.min(s.b.y), s.a.y.max(s.b.y)) + } else { + ((s.a.y - a.1).abs() < 0.75 && (s.b.y - a.1).abs() < 0.75, s.a.x.min(s.b.x), s.a.x.max(s.b.x)) + }; + let overlap = s1.min(hi) - s0.max(lo); + if on && overlap > 0.0 { + covered += overlap; + first.get_or_insert(s); + } + } + first.filter(|_| covered > 0.5 * (hi - lo)) +} + +fn border(ex: &mut Exporter, tag: &str, s: Option<&StrokeSeg>, k: f64, opacity: f32) -> String { + let Some(s) = s.filter(|s| s.stroke.weight > 0.0) else { return format!("") }; + let st = &s.stroke; + let Some(c) = ex.color(&st.color, st.tint, opacity) else { return format!("") }; + let dash = match st.kind { + StrokeType::Dashed { .. } => "dash", + StrokeType::Dotted => "sysDot", + _ => "solid", + }; + format!( + "{c}", + emu_len(st.weight * k) + ) +} + +fn table(ex: &mut Exporter, t: &TableFrag, m: Affine, k: f64, opacity: f32) { + if t.cells.is_empty() { + return; + } + let Some((xfrm, dm)) = box_of(t.rect, m) else { return }; + if dm.rot.abs() > 0.01 || xfrm.flip_v { + ex.warn("tables in rotated or flipped frames are exported upright"); + } + let xs = edges(t.cells.iter().flat_map(|c| [c.rect.x0, c.rect.x1]).collect()); + let ys = edges(t.cells.iter().flat_map(|c| [c.rect.y0, c.rect.y1]).collect()); + if xs.len() < 2 || ys.len() < 2 { + return; + } + let (nc, nr) = (xs.len() - 1, ys.len() - 1); + // Which placed cell starts at each grid position, and which positions merged cells cover. + let mut origin: Vec> = vec![None; nc * nr]; + let mut covered: Vec<(bool, bool)> = vec![(false, false); nc * nr]; + let mut span: Vec<(usize, usize)> = vec![(1, 1); nc * nr]; + for c in &t.cells { + let (c0, c1) = (index(&xs, c.rect.x0), index(&xs, c.rect.x1)); + let (r0, r1) = (index(&ys, c.rect.y0), index(&ys, c.rect.y1)); + if c1 <= c0 || r1 <= r0 { + continue; + } + let Some(slot) = origin.get_mut(r0 * nc + c0) else { continue }; + *slot = Some(c); + if let Some(sp) = span.get_mut(r0 * nc + c0) { + *sp = (c1 - c0, r1 - r0); + } + for r in r0..r1 { + for col in c0..c1 { + if (r, col) != (r0, c0) + && let Some(cv) = covered.get_mut(r * nc + col) + { + *cv = (col > c0, r > r0); + } + } + } + } + if t.cells.iter().any(|c| c.graphic.is_some()) { + ex.warn("graphic cells are exported empty"); + } + let id = ex.id(); + let mut s = format!( + "\ +", + emu(xfrm.rect.x0), + emu(xfrm.rect.y0), + emu_len(xfrm.rect.width()), + emu_len(xfrm.rect.height()) + ); + for w in xs.windows(2) { + let _ = write!(s, "", emu_len((w[1] - w[0]) * k)); + } + s.push_str(""); + for r in 0..nr { + let h = ys.get(r + 1).zip(ys.get(r)).map_or(0.0, |(b, a)| b - a); + let _ = write!(s, "", emu_len(h * k)); + for col in 0..nc { + let at = r * nc + col; + let (hm, vm) = covered.get(at).copied().unwrap_or((false, false)); + if hm || vm { + let _ = write!( + s, + "", + if hm { " hMerge=\"1\"" } else { "" }, + if vm { " vMerge=\"1\"" } else { "" } + ); + continue; + } + let (gs, rs) = span.get(at).copied().unwrap_or((1, 1)); + let mut attrs = String::new(); + if gs > 1 { + let _ = write!(attrs, " gridSpan=\"{gs}\""); + } + if rs > 1 { + let _ = write!(attrs, " rowSpan=\"{rs}\""); + } + let Some(c) = origin.get(at).copied().flatten() else { + let _ = write!( + s, + "" + ); + continue; + }; + s.push_str(&cell(ex, c, &attrs, t, k, opacity)); + } + s.push_str(""); + } + s.push_str(""); + ex.out.push_str(&s); +} + +fn cell(ex: &mut Exporter, c: &PlacedCell, attrs: &str, t: &TableFrag, k: f64, opacity: f32) -> String { + let rc = c.rect; + let mut body = String::new(); + let mut margins = [0.0f64; 4]; + if let Some(cft) = c.text.frames.first() { + let segs = segments(ex, &c.text, cft, &c.source, &[]); + // A cell holds one box: its lines, from the first segment's slot. + let mut all = segs.first().cloned().unwrap_or_default(); + for sg in segs.iter().skip(1) { + all.lines.extend(sg.lines.iter().cloned()); + all.x1 = all.x1.max(sg.x1); + } + if !all.lines.is_empty() { + let r = seg_rect(&all); + let r = Rect::new(r.x0 + c.origin.x, r.y0 + c.origin.y, r.x1 + c.origin.x, r.y1 + c.origin.y); + margins = [(r.x0 - rc.x0).max(0.0), (r.y0 - rc.y0).max(0.0), (rc.x1 - r.x1).max(0.0), (rc.y1 - r.y1).max(0.0)]; + let local = Rect::new(r.x0 - c.origin.x, r.y0 - c.origin.y, r.x1 - c.origin.x, r.y1 - c.origin.y); + body = paragraphs(ex, &all, local, k, opacity); + } + } + if body.is_empty() { + body = "".into(); + } + let [l, tp, r, b] = margins.map(|v| emu_len(v * k)); + let ln = |side: char| -> ((f64, f64), (f64, f64)) { + match side { + 'L' => ((rc.x0, rc.y0), (rc.x0, rc.y1)), + 'R' => ((rc.x1, rc.y0), (rc.x1, rc.y1)), + 'T' => ((rc.x0, rc.y0), (rc.x1, rc.y0)), + _ => ((rc.x0, rc.y1), (rc.x1, rc.y1)), + } + }; + let mut pr = format!(""); + for (tag, side) in [("lnL", 'L'), ("lnR", 'R'), ("lnT", 'T'), ("lnB", 'B')] { + let (a, bb) = ln(side); + let seg = edge_stroke(&t.strokes, a, bb); + pr.push_str(&border(ex, tag, seg, k, opacity)); + } + match c.fill.as_ref().and_then(|(sw, tint)| ex.color(sw, *tint, opacity)) { + Some(col) => { + let _ = write!(pr, "{col}"); + } + None => pr.push_str(""), + } + pr.push_str(""); + format!("{body}{pr}") +} diff --git a/crates/pptx/src/tests.rs b/crates/pptx/src/tests.rs new file mode 100644 index 00000000..0ee9a61c --- /dev/null +++ b/crates/pptx/src/tests.rs @@ -0,0 +1,271 @@ +//! Package structure and object mapping tests. + +use std::io::Read; +use std::sync::Arc; + +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{ + Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Stroke, StrokeType, TextFrameOptions, +}; +use designcraft_geom::{Affine, Rect, shapes}; + +use crate::{LineBreaks, PptxError, PptxOptions, export_pptx}; + +fn unzip(bytes: &[u8]) -> zip::ZipArchive>> { + zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap() +} + +fn part(z: &mut zip::ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + z.by_name(name).unwrap_or_else(|_| panic!("missing {name}")).read_to_string(&mut s).unwrap(); + s +} + +fn export(d: &Document, opts: &PptxOptions) -> zip::ZipArchive>> { + unzip(&export_pptx(d, &Cache::new(), opts).unwrap().bytes) +} + +fn rect_item(d: &mut Document, r: Rect, shape: Shape) -> ItemId { + let id = ItemId(d.alloc()); + let path = if shape == Shape::Oval { shapes::ellipse(r) } else { shapes::rectangle(r) }; + let mut it = Item::new(id, d.default_layer(), shape, path); + it.fill = Fill::swatch("[Black]"); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + id +} + +fn edit(d: &mut Document, id: ItemId, f: impl FnOnce(&mut Item)) { + let it = d.spreads.iter_mut().flat_map(|sp| Arc::make_mut(sp).items.iter_mut()).find(|it| it.id == id).unwrap(); + f(Arc::make_mut(it)); +} + +/// The text of each shape on a slide (runs joined). +fn shape_texts(slide: &str) -> Vec { + slide + .split("") + .skip(1) + .map(|sp| sp.split("").skip(1).filter_map(|t| t.split("").next()).collect::()) + .filter(|t| !t.is_empty()) + .collect() +} + +/// Every XML part parses. +fn well_formed(z: &mut zip::ZipArchive>>) { + let names: Vec = z.file_names().map(str::to_string).collect(); + for n in names.iter().filter(|n| n.ends_with(".xml") || n.ends_with(".rels")) { + let x = part(z, n); + let mut r = quick_xml::Reader::from_str(&x); + loop { + match r.read_event() { + Ok(quick_xml::events::Event::Eof) => break, + Ok(_) => {} + Err(e) => panic!("{n}: {e}"), + } + } + } +} + +#[test] +fn package_has_the_parts_powerpoint_needs() { + let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello & ", ParaFormat::default()).unwrap(); + let r = export_pptx(&d, &Cache::new(), &PptxOptions::default()).unwrap(); + assert_eq!(r.slides, 2); + let mut z = unzip(&r.bytes); + for p in [ + "[Content_Types].xml", + "_rels/.rels", + "ppt/presentation.xml", + "ppt/_rels/presentation.xml.rels", + "ppt/slideMasters/slideMaster1.xml", + "ppt/slideLayouts/slideLayout1.xml", + "ppt/theme/theme1.xml", + "ppt/slides/slide1.xml", + "ppt/slides/slide2.xml", + "ppt/slides/_rels/slide2.xml.rels", + "docProps/core.xml", + ] { + assert!(z.by_name(p).is_ok(), "{p}"); + } + well_formed(&mut z); + let pres = part(&mut z, "ppt/presentation.xml"); + let (w, h) = (d.settings.page_width, d.settings.page_height); + assert!(pres.contains(&format!("", (w * 12_700.0).round() as i64, (h * 12_700.0).round() as i64)), "{pres}"); + let s1 = part(&mut z, "ppt/slides/slide1.xml"); + assert_eq!(shape_texts(&s1), ["Hello & <world>"]); +} + +#[test] +fn shapes_keep_geometry_fill_stroke_and_effects() { + let mut d = Document::new(&NewDocument::default()); + let a = rect_item(&mut d, Rect::new(72.0, 72.0, 172.0, 122.0), Shape::Rectangle); + let b = rect_item(&mut d, Rect::new(200.0, 72.0, 300.0, 122.0), Shape::Oval); + let c = rect_item(&mut d, Rect::new(72.0, 200.0, 172.0, 300.0), Shape::Rectangle); + edit(&mut d, a, |it| { + it.stroke = Stroke { swatch: "[Black]".into(), weight: 2.0, kind: StrokeType::Dashed { pattern: vec![4.0, 2.0] }, ..Stroke::default() }; + it.opacity = 0.5; + }); + edit(&mut d, b, |it| it.effects.drop_shadow.on = true); + edit(&mut d, c, |it| { + let ctr = it.bounds().center(); + it.xf = Affine::rotate_about(30f64.to_radians(), ctr); + }); + let mut z = export(&d, &PptxOptions::default()); + let s = part(&mut z, "ppt/slides/slide1.xml"); + // 72 pt from the page corner, 100 × 50 pt. + assert!(s.contains(""), "{s}"); + assert!(s.contains("")); + assert!(s.contains(""), "{s}"); + assert!(s.contains("")); + assert!(s.contains(" 3, "the frame breaks the text into lines"); + let r = export_pptx(&d, &cache, &PptxOptions::default()).unwrap(); + let mut z = unzip(&r.bytes); + let s = part(&mut z, "ppt/slides/slide1.xml"); + assert_eq!(s.matches("").count(), lines - 1, "{s}"); + // The text survives intact (line-end spaces kept, the composer's hyphens shown). + let joined = shape_texts(&s).concat(); + assert_eq!(joined.replace('-', ""), text); + assert!(s.contains("")); + assert_eq!(s.matches("").count(), 2); +} + +#[test] +fn threaded_story_splits_at_its_frames() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega"; + let (f1, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 160.0, 110.0), lid, text, ParaFormat::default()).unwrap(); + let f2 = rect_item(&mut d, Rect::new(300.0, 72.0, 388.0, 400.0), Shape::Rectangle); + edit(&mut d, f2, |it| { + it.fill = Fill::none(); + it.content = Content::Text(designcraft_doc::TextFrame { story: sid, options: TextFrameOptions::default() }); + }); + if let Some(st) = d.stories.get_mut(&sid) { + Arc::make_mut(st).frames = vec![f1, f2]; + } + let mut z = export(&d, &PptxOptions::default()); + let s = part(&mut z, "ppt/slides/slide1.xml"); + // Two text shapes, the story starting in the first and ending in the second. + let texts: Vec = shape_texts(&s).iter().map(|t| t.replace('-', "")).collect(); + assert_eq!(texts.len(), 2, "{s}"); + assert!(texts[0].starts_with("alpha") && !texts[0].contains("omega"), "{texts:?}"); + assert!(texts[1].trim_end().ends_with("omega"), "{texts:?}"); + assert_eq!(texts.concat().trim_end(), text); +} + +fn png(w: u32, h: u32) -> Vec { + let img = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255])); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() +} + +#[test] +fn pictures_are_cropped_to_their_frames() { + let mut d = Document::new(&NewDocument::default()); + let asset = AssetId(d.alloc()); + d.assets.insert( + asset, + Arc::new(Asset { + id: asset, + name: "red.png".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(png(200, 100)), + pixels: Some((200, 100)), + page: 0, + }), + ); + let id = rect_item(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0), Shape::Rectangle); + edit(&mut d, id, |it| { + it.fill = Fill::none(); + // 200 × 100 image at 1:1, its left quarter cut off by the frame. + it.content = Content::Graphic(Graphic { + asset, + size: (200.0, 100.0), + xf: Affine::translate((50.0, 100.0)), + auto_fit: designcraft_doc::Fitting::None, + fit_align: 0, + crop: [0.0; 4], + }); + }); + let mut z = export(&d, &PptxOptions::default()); + assert!(z.by_name("ppt/media/image1.png").is_ok()); + let s = part(&mut z, "ppt/slides/slide1.xml"); + assert!(s.contains(""), "{s}"); + let rels = part(&mut z, "ppt/slides/_rels/slide1.xml.rels"); + assert!(rels.contains("../media/image1.png")); +} + +#[test] +fn bad_pages_and_hostile_geometry_are_errors_not_crashes() { + let mut d = Document::new(&NewDocument::default()); + let r = export_pptx(&d, &Cache::new(), &PptxOptions { pages: Some(vec![5]), ..Default::default() }); + assert!(matches!(r, Err(PptxError::BadPage(6)))); + assert!(matches!(export_pptx(&d, &Cache::new(), &PptxOptions { pages: Some(vec![]), ..Default::default() }), Err(PptxError::NoPages))); + let a = rect_item(&mut d, Rect::new(10.0, 10.0, 10.0, 10.0), Shape::Rectangle); + let b = rect_item(&mut d, Rect::new(10.0, 10.0, 50.0, 50.0), Shape::Rectangle); + edit(&mut d, a, |it| it.xf = Affine::new([0.0, 0.0, 0.0, 0.0, 0.0, 0.0])); + edit(&mut d, b, |it| it.xf = Affine::new([f64::NAN, 0.0, 0.0, 1e300, f64::INFINITY, 0.0])); + let lid = d.default_layer(); + let (t, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 60.0, 60.0), lid, "x", ParaFormat::default()).unwrap(); + edit(&mut d, t, |it| it.xf = Affine::new([1.0, 0.0, 3.0, 1.0, 0.0, 0.0])); + let mut z = export(&d, &PptxOptions::default()); + well_formed(&mut z); +} + +#[test] +fn fonts_embed_as_embedded_opentype() { + let face = designcraft_fonts::FontDb::global().face("Source Serif 4", "Regular"); + let eot = crate::fonts::eot(&face).unwrap(); + let le32 = |at: usize| u32::from_le_bytes([eot[at], eot[at + 1], eot[at + 2], eot[at + 3]]); + assert_eq!(le32(0) as usize, eot.len()); + assert_eq!(le32(4) as usize, face.data().len()); + assert_eq!(le32(8), 0x0002_0001); + assert_eq!(u16::from_le_bytes([eot[34], eot[35]]), 0x504C, "magic"); + assert!(eot.ends_with(face.data())); + + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Embedded", ParaFormat::default()).unwrap(); + let mut z = export(&d, &PptxOptions { embed_fonts: true, ..Default::default() }); + let pres = part(&mut z, "ppt/presentation.xml"); + assert!( + pres.contains("embedTrueTypeFonts=\"1\"") && pres.contains(" bool { + ('\u{E000}'..='\u{E1FF}').contains(&c) +} + +/// One piece of a line: a cluster's text and where the composer put it. +#[derive(Clone, Debug)] +struct Unit { + text: String, + style: u32, + face: designcraft_fonts::FaceRef, + size: f64, + /// Baseline shift (points, down positive). + y: f64, + x: f64, + end: f64, + /// Width PowerPoint gives the text without extra spacing. + natural: f64, + tab: bool, + rs: Option, +} + +/// A run of units sharing style and character spacing. +#[derive(Clone, Debug)] +struct Run { + text: String, + style: u32, + face: designcraft_fonts::FaceRef, + size: f64, + y: f64, + /// Character spacing, points. + spc: f64, + rs: Option, +} + +/// A laid-out line ready to write. +#[derive(Clone, Debug)] +pub(crate) struct OutLine { + runs: Vec, + /// Text-space x where the line's first unit starts. + start: f64, + /// Text-space x where it ends. + end: f64, + baseline: f64, + descent: f64, + /// Largest font size on the line (PowerPoint's line height follows it). + size: f64, + /// The composer's leading for the line. + leading: f64, + tabs: Vec, + para: usize, + last_in_para: bool, + /// The line ends at a space the composer doesn't draw. + trailing_space: bool, +} + +/// Lines that share a text box. +#[derive(Clone, Debug, Default)] +pub(crate) struct Segment { + pub lines: Vec, + pub x0: f64, + pub x1: f64, +} + +pub fn frame(ex: &mut Exporter, it: &Item, tfr: &TextFrame, xf: Affine, opacity: f32) { + let doc = ex.doc; + let cs = ex.cache.get(doc, tfr.story, ex.page_name.as_deref()); + let Some(ft) = cs.frame(it.id) else { + ex.shape(it, xf, opacity, None); + return; + }; + let story_text = doc.story(tfr.story).map(|s| s.text.clone()).unwrap_or_default(); + let vertical = doc.frame_vertical(it); + if vertical { + ex.warn("vertical text frames are exported with PowerPoint's vertical text (line breaks may differ)"); + } + let m = ex.to_slide * xf; + if ex.opts.line_breaks == LineBreaks::Reflow || vertical { + reflow(ex, it, tfr, ft, &cs, &story_text, xf, opacity, vertical); + return; + } + let Some((_, dm)) = box_of(it.inner_bounds(), m) else { + ex.warn("skewed text frames are exported unskewed"); + // Upright where the skewed frame's box is (its parents' transforms included). + let inner = it.inner_bounds(); + let mut plain = it.clone(); + plain.xf = Affine::translate(xf.transform_rect_bbox(inner).origin().to_vec2() - inner.origin().to_vec2()); + frame(ex, &plain, tfr, plain.xf, opacity); + return; + }; + let k = (dm.sx * dm.sy).abs().sqrt(); + let table_lines: Vec = ft.tables.iter().map(|t| t.line).collect(); + let segs = segments(ex, &cs, ft, &story_text, &table_lines); + let simple = segs.len() == 1 && ft.notes.is_empty() && ft.tables.is_empty() && ft.decos.is_empty(); + if simple && let Some(seg) = segs.first() { + // One box: the frame itself carries the text, inset to where the lines sit. + let inner = it.inner_bounds(); + let r = seg_rect(seg); + let insets = [r.x0 - inner.x0, r.y0 - inner.y0, inner.x1 - r.x1, inner.y1 - r.y1]; + if insets.iter().all(|v| *v >= -0.01) { + let body = body(ex, seg, r, k, opacity, insets); + ex.shape(it, xf, opacity, Some(&body)); + return; + } + } + ex.shape(it, xf, opacity, None); + decos(ex, ft, m, opacity); + for seg in &segs { + text_box(ex, seg, m, k, opacity, "Text"); + } + for n in &ft.notes { + if let Some(nft) = n.text.frames.first() { + let nm = m * Affine::translate(n.origin.to_vec2()); + let note = segments(ex, &n.text, nft, &n.source, &[]); + decos(ex, nft, nm, opacity); + for seg in ¬e { + text_box(ex, seg, nm, k, opacity, "Footnote"); + } + } + } + crate::table::tables(ex, ft, m, k, opacity); + anchored(ex, tfr, ft, xf, opacity); +} + +/// Anchored objects in the frame's text. +fn anchored(ex: &mut Exporter, tfr: &TextFrame, ft: &FrameText, xf: Affine, opacity: f32) { + let Some(st) = ex.doc.story(tfr.story) else { return }; + let objects: Vec<_> = ft.objects.iter().filter_map(|o| st.objects.get(o.index).map(|obj| (obj.item.clone(), o.origin))).collect(); + for (item, origin) in objects { + ex.item(&item, xf * Affine::translate(origin.to_vec2()), opacity); + } +} + +/// Paragraph shading and rules. +fn decos(ex: &mut Exporter, ft: &FrameText, m: Affine, opacity: f32) { + for d in &ft.decos { + let Some(c) = ex.color(&d.color, d.tint, opacity) else { continue }; + if let Some((xfrm, _)) = box_of(d.rect, m) { + ex.rect_shape(xfrm, &format!("{c}"), "Paragraph Shading"); + } + } +} + +pub(crate) fn seg_rect(seg: &Segment) -> Rect { + let first = seg.lines.first(); + let top = first.map_or(0.0, |l| l.baseline - BASELINE_IN_LINE * line_height(seg, 0)); + let bottom = seg.lines.last().map_or(top + 1.0, |l| l.baseline + l.descent); + Rect::new(seg.x0, top, seg.x1.max(seg.x0 + 1.0), bottom.max(top + 1.0)) +} + +/// The PowerPoint line height used for line `i` of a segment: the baseline distance inside its +/// paragraph, else the composer's leading for the line. +fn line_height(seg: &Segment, i: usize) -> f64 { + let Some(l) = seg.lines.get(i) else { return 12.0 }; + para_step(seg, i).unwrap_or(if l.leading > 0.0 { l.leading } else { l.size * SINGLE }) +} + +/// The baseline-to-baseline distance inside line `i`'s paragraph (the median, in this segment). +fn para_step(seg: &Segment, i: usize) -> Option { + let para = seg.lines.get(i)?.para; + let ls: Vec<&OutLine> = seg.lines.iter().filter(|l| l.para == para).collect(); + let mut d: Vec = ls.windows(2).map(|w| w[1].baseline - w[0].baseline).filter(|v| *v > 0.0).collect(); + if d.is_empty() { + return None; + } + d.sort_by(f64::total_cmp); + d.get(d.len() / 2).copied() +} + +fn text_box(ex: &mut Exporter, seg: &Segment, m: Affine, k: f64, opacity: f32, name: &str) { + let r = seg_rect(seg); + let Some((xfrm, _)) = box_of(r, m) else { return }; + let body = body(ex, seg, r, k, opacity, [0.0; 4]); + write_box(ex, xfrm, &body, name); +} + +fn write_box(ex: &mut Exporter, xfrm: XfrmBox, body: &str, name: &str) { + let id = ex.id(); + let _ = write!( + ex.out, + "{}{body}", + xfrm.xml() + ); +} + +/// Cut a frame's lines into segments and lay out their runs. +pub(crate) fn segments(ex: &mut Exporter, cs: &ComposedStory, ft: &FrameText, story: &str, skip: &[usize]) -> Vec { + let mut out: Vec = Vec::new(); + let mut prev: Option<&Line> = None; + for (i, l) in ft.lines.iter().enumerate() { + if skip.contains(&i) { + prev = None; + continue; + } + let ol = out_line(ex, cs, l, story); + let joins = prev.is_some_and(|p| { + p.column == l.column + && (p.x0 - l.x0).abs() < 0.5 + && (p.x1 - l.x1).abs() < 0.5 + && l.baseline > p.baseline + && l.baseline - p.baseline < 2.5 * p.leading.max(l.leading).max(ol.size) + }); + match out.last_mut() { + Some(seg) if joins => seg.lines.push(ol), + _ => out.push(Segment { lines: vec![ol], x0: l.x0, x1: l.x1 }), + } + prev = Some(l); + } + out +} + +/// The text for glyph `g`: the story's characters, or what the glyph shows when the composer +/// inserted or changed it (page numbers, list labels, hyphens, case changes). +fn glyph_text(ex: &mut Exporter, g: &PlacedGlyph, story: &str, prev_src: &mut Option) -> Option { + let src = story.get(g.byte..g.byte.saturating_add(g.len)).filter(|t| !t.is_empty() && !t.chars().any(is_marker)); + let face = g.face.get(); + if let Some(t) = src { + *prev_src = Some(g.byte); + // A single character drawn as another glyph (All Caps): what the glyph shows. + let mut cs = t.chars(); + if let (Some(c), None) = (cs.next(), cs.next()) + && let Some(shown) = ex.fonts.char_of(face, g.gid) + && shown != c + && ex.fonts.glyph(face, c) != Some(g.gid) + && shown.to_lowercase().eq(c.to_lowercase()) + { + return Some(shown.to_string()); + } + return Some(t.to_string()); + } + if g.len == 0 && *prev_src == Some(g.byte) { + // Further glyphs of a cluster already written. + return None; + } + *prev_src = None; + ex.fonts.char_of(face, g.gid).map(|c| c.to_string()) +} + +fn out_line(ex: &mut Exporter, cs: &ComposedStory, l: &Line, story: &str) -> OutLine { + let mut units: Vec = Vec::new(); + let mut prev_src = None; + let is_tab = |g: &PlacedGlyph| story.get(g.byte..g.byte.saturating_add(g.len)) == Some("\t"); + let last_shown = l.glyphs.iter().rposition(|g| g.visible || is_tab(g)); + let mut trailing_space = false; + for (i, g) in l.glyphs.iter().enumerate() { + let next_x = l.glyphs.get(i + 1).map_or(g.x + g.adv, |n| n.x); + let is_tab = is_tab(g); + if !g.visible && !is_tab { + if last_shown.is_some_and(|k| i > k) { + trailing_space |= story.get(g.byte..g.byte.saturating_add(g.len)).is_some_and(|t| t.chars().any(char::is_whitespace)); + } else if let Some(u) = units.last_mut() { + // Invisible glyphs inside the line still take up their room. + u.end = next_x.max(u.end); + } + continue; + } + let face = g.face.get(); + let size = g.sy * face.upem; + let text = if is_tab { Some("\t".to_string()) } else { glyph_text(ex, g, story, &mut prev_src) }; + let Some(text) = text else { + if let Some(u) = units.last_mut() { + u.end = next_x.max(u.end); + } + continue; + }; + let natural: f64 = if is_tab { 0.0 } else { text.chars().map(|c| ex.fonts.advance(face, c).unwrap_or(face.upem * 0.5) * g.sy).sum() }; + let rs = cs.styles.get(g.style as usize).cloned(); + units.push(Unit { text, style: g.style, face: g.face, size, y: g.y, x: g.x, end: next_x, natural, tab: is_tab, rs }); + } + // A space the composer left out of the line (it ends there): in the story at the line's end. + let end_byte = l.glyphs.iter().filter(|g| g.len > 0).map(|g| g.byte.saturating_add(g.len)).max().unwrap_or(l.range.end); + let after = story.get(end_byte.min(story.len())..).and_then(|t| t.chars().next()); + trailing_space |= !l.last_in_para && after.is_some_and(|c| c.is_whitespace() && c != '\n' && c != '\r'); + let start = units.first().map_or(l.x0, |u| u.x); + let end = units.last().map_or(l.x0, |u| u.end); + let mut runs: Vec = Vec::new(); + let mut tabs = Vec::new(); + // PowerPoint's pen, and the spacing of the run it is in. + let mut pen = start; + for u in &units { + if u.tab { + tabs.push(u.end); + runs.push(Run { text: "\t".into(), style: u.style, face: u.face, size: u.size, y: u.y, spc: 0.0, rs: u.rs.clone() }); + pen = u.end; + continue; + } + let n = u.text.chars().count().max(1) as f64; + if let Some(r) = runs.last_mut().filter(|r| !r.text.ends_with('\t')) + && r.style == u.style + && r.face.get().id() == u.face.get().id() + && (r.size - u.size).abs() < 0.005 + && (r.y - u.y).abs() < 0.01 + && (pen + u.natural + r.spc * n - u.end).abs() <= TOLERANCE + { + r.text.push_str(&u.text); + pen += u.natural + r.spc * n; + continue; + } + // A new run whose spacing lands this unit where the composer put it (0.01 pt steps). + let spc = ((u.end - pen - u.natural) / n * 100.0).round() / 100.0; + let spc = if spc.is_finite() { spc.clamp(-100.0, 100.0) } else { 0.0 }; + runs.push(Run { text: u.text.clone(), style: u.style, face: u.face, size: u.size, y: u.y, spc, rs: u.rs.clone() }); + pen += u.natural + spc * n; + } + let size = units.iter().map(|u| u.size).fold(0.0, f64::max); + OutLine { + runs, + start, + end, + baseline: l.baseline, + descent: l.descent, + size: if size > 0.0 { size } else { (l.ascent + l.descent).max(1.0) / SINGLE }, + leading: l.leading, + tabs, + para: l.para, + last_in_para: l.last_in_para, + trailing_space, + } +} + +/// Paragraph alignment read off the lines: centred and right-aligned lines leave room on both or +/// the left side; everything else starts at its left indent. +fn align_of(seg: &Segment, lines: &[OutLine]) -> &'static str { + let gaps: Vec<(f64, f64)> = lines.iter().map(|l| (l.start - seg.x0, seg.x1 - l.end)).collect(); + let roomy = |g: &(f64, f64)| g.0 > 1.0 || g.1 > 1.0; + if !gaps.iter().any(roomy) { + return "l"; + } + if gaps.iter().all(|(a, b)| (a - b).abs() < 0.75) && gaps.iter().any(|(a, _)| *a > 1.0) { + return "ctr"; + } + // Flush right with room on the left: more than an indented first line of justified text. + let indented = gaps.iter().filter(|(a, _)| *a > 1.0).count(); + if gaps.iter().all(|(_, b)| *b < 0.75) && (indented > 1 || (gaps.len() == 1 && indented == 1)) { + return "r"; + } + "l" +} + +/// `` for a segment whose box is `r` (text space); `insets` place the text inside a +/// larger shape (left, top, right, bottom). +fn body(ex: &mut Exporter, seg: &Segment, r: Rect, k: f64, opacity: f32, insets: [f64; 4]) -> String { + let [li, ti, ri, bi] = insets.map(|v| emu_len(v * k)); + format!( + "{}", + paragraphs(ex, seg, r, k, opacity) + ) +} + +/// The `` elements of a segment whose box is `r` (text space). +pub(crate) fn paragraphs(ex: &mut Exporter, seg: &Segment, r: Rect, k: f64, opacity: f32) -> String { + let mut s = String::new(); + let mut prev_end: Option<(f64, f64)> = None; + // The segment's lines, a paragraph at a time; `at` is the index of each one's first line. + let mut at = 0; + for lines in seg.lines.chunk_by(|a, b| a.para == b.para && !a.last_in_para) { + let i = at; + at += lines.len(); + let (Some(first), Some(last)) = (lines.first(), lines.last()) else { continue }; + let mut h = line_height(seg, i); + let r_in = BASELINE_IN_LINE; + if let Some((prev_base, prev_h)) = prev_end + && lines.len() == 1 + { + // A one-line paragraph closer to the line before than its own line box allows (tight + // display type): a smaller box puts it there. + let natural = (1.0 - r_in) * prev_h + r_in * h; + let gap = first.baseline - prev_base; + if gap < natural { + h = ((gap - (1.0 - r_in) * prev_h) / r_in).max(first.size * 0.2); + } + } + let algn = align_of(seg, lines); + let mut ppr = format!(" 0.005 { + let _ = write!(ppr, " marL=\"{}\"", emu(rest * k)); + } + if ind.abs() > 0.005 { + let _ = write!(ppr, " indent=\"{}\"", emu(ind * k)); + } + } + ppr.push('>'); + let _ = write!(ppr, "", pct(h / (first.size * SINGLE))); + if let Some((prev_base, prev_h)) = prev_end { + // PowerPoint puts the next line one line box down: the rest of the previous line's box + // and the top part of this one. Space Before makes up the difference. + let natural = (1.0 - r_in) * prev_h + r_in * h; + let before = first.baseline - prev_base - natural; + if before > 0.005 { + let _ = write!(ppr, "", (before * k * 100.0).round() as i64); + } + } + let tabs: Vec = lines.iter().flat_map(|l| l.tabs.iter().copied()).collect(); + if !tabs.is_empty() { + ppr.push_str(""); + let mut sorted = tabs.clone(); + sorted.sort_by(f64::total_cmp); + sorted.dedup_by(|a, b| (*a - *b).abs() < 0.01); + for t in sorted { + let _ = write!(ppr, "", emu((t - r.x0) * k)); + } + ppr.push_str(""); + } + ppr.push_str(""); + s.push_str(""); + s.push_str(&ppr); + for (n, l) in lines.iter().enumerate() { + if n > 0 { + let _ = write!(s, "{}", rpr(ex, l.runs.first(), l.size, k, opacity, "a:rPr")); + } + let n_runs = l.runs.len(); + for (ri, run) in l.runs.iter().enumerate() { + // The undrawn space at a left-aligned line's end goes back in (invisible there), + // so removing the break doesn't join words. + let tail = if ri + 1 == n_runs && l.trailing_space && algn == "l" && !run.text.ends_with(char::is_whitespace) { " " } else { "" }; + let _ = write!(s, "{}{}{tail}", rpr(ex, Some(run), run.size, k, opacity, "a:rPr"), esc(&run.text)); + } + } + s.push_str(&rpr(ex, last.runs.last(), last.size, k, opacity, "a:endParaRPr")); + s.push_str(""); + prev_end = Some((last.baseline, h)); + } + s +} + +/// Run properties (`a:rPr`, or `a:endParaRPr` for the paragraph mark). +fn rpr(ex: &mut Exporter, run: Option<&Run>, size: f64, k: f64, opacity: f32, tag: &str) -> String { + // PowerPoint draws raised and lowered text at 2/3 of its size: state it larger. + let shifted = run.is_some_and(|r| r.y.abs() > 0.01 && r.size > 0.0); + let size = if shifted { size * 1.5 } else { size }; + let sz = ((size * k * 100.0).round() as i64).clamp(100, 400_000); + let Some(run) = run else { return format!("<{tag} lang=\"en-US\" sz=\"{sz}\" dirty=\"0\"/>") }; + let cs = run.rs.as_ref(); + let font = ex.fonts.use_face(run.face); + let mut a = format!("<{tag} lang=\"en-US\" sz=\"{sz}\""); + let italic = font.italic || cs.is_some_and(|c| c.skew.abs() > 0.01); + if font.bold { + a.push_str(" b=\"1\""); + } + if italic { + a.push_str(" i=\"1\""); + } + if let Some(st) = cs { + if st.underline { + a.push_str(" u=\"sng\""); + } + if st.strikethrough { + a.push_str(" strike=\"sngStrike\""); + } + } + // Kerning off: the composer's kerning is in the character spacing already. + a.push_str(" kern=\"400000\""); + let spc = (run.spc * k * 100.0).round() as i64; + if spc != 0 && tag == "a:rPr" { + let _ = write!(a, " spc=\"{spc}\""); + } + if shifted { + let _ = write!(a, " baseline=\"{}\"", pct(-run.y / size)); + } + a.push_str(" dirty=\"0\">"); + if let Some(st) = cs { + if st.stroke != designcraft_color::swatch::NONE + && st.stroke_weight > 0.0 + && let Some(c) = ex.color(&st.stroke, st.stroke_tint, opacity) + { + let _ = write!(a, "{c}", emu_len(st.stroke_weight * k)); + } + match ex.color(&st.fill, st.fill_tint, opacity) { + Some(c) => { + let _ = write!(a, "{c}"); + } + None => a.push_str(""), + } + } + let fam = esc(&font.family); + let _ = write!(a, ""); + a +} + +/// Reflow: the frame as one shape whose text PowerPoint sets, in the frame's columns. +#[allow(clippy::too_many_arguments)] +fn reflow(ex: &mut Exporter, it: &Item, tfr: &TextFrame, ft: &FrameText, cs: &ComposedStory, story: &str, xf: Affine, opacity: f32, vertical: bool) { + let doc = ex.doc; + let Some(st) = doc.story(tfr.story) else { + ex.shape(it, xf, opacity, None); + return; + }; + let o = &tfr.options; + let m = ex.to_slide * xf; + let k = box_of(it.inner_bounds(), m).map_or(1.0, |(_, d)| (d.sx * d.sy).abs().sqrt()); + let [t, l, b, r] = o.inset.map(|v| emu_len(v * k)); + let cols = ft.columns.len().max(1); + let mut s = format!(" 1 { + let _ = write!(s, " numCol=\"{cols}\" spcCol=\"{}\"", emu_len(o.gutter * k)); + } + if vertical { + s.push_str(" vert=\"eaVert\""); + } + let anchor = match o.vertical_justification { + designcraft_doc::VerticalJustification::Center => "ctr", + designcraft_doc::VerticalJustification::Bottom => "b", + designcraft_doc::VerticalJustification::Justify => "just", + designcraft_doc::VerticalJustification::Top => "t", + }; + let _ = write!(s, " anchor=\"{anchor}\">"); + // The paragraphs this frame shows, with the runs of their composed glyphs. + let ranges = st.para_ranges(); + let mut first = true; + for (pi, range) in ranges.iter().enumerate() { + if range.end < ft.range.start || range.start >= ft.range.end.max(ft.range.start + 1) { + continue; + } + let lines: Vec<&Line> = ft.lines.iter().filter(|l| l.para == pi).collect(); + let Some(pf) = st.paras.get(pi) else { continue }; + let (pp, chars) = doc.styles.resolve_para(pf); + let algn = match pp.align { + Align::Center => "ctr", + Align::Right => "r", + Align::LeftJustified | Align::CenterJustified | Align::RightJustified => "just", + Align::FullyJustified => "dist", + _ => "l", + }; + let mut ppr = format!(" 0.005 { + let _ = write!(ppr, " marL=\"{}\"", emu(pp.left_indent * k)); + } + if pp.right_indent.abs() > 0.005 { + let _ = write!(ppr, " marR=\"{}\"", emu(pp.right_indent * k)); + } + if pp.first_line_indent.abs() > 0.005 { + let _ = write!(ppr, " indent=\"{}\"", emu(pp.first_line_indent * k)); + } + ppr.push('>'); + let size = chars.size.max(1.0); + let lead = match lines.windows(2).map(|w| w[1].baseline - w[0].baseline).find(|d| *d > 0.0) { + Some(d) => d, + None => lines.first().map_or(size * pp.auto_leading, |l| l.leading.max(1.0)), + }; + let _ = write!(ppr, "", pct(lead / (size * SINGLE))); + if !first && pp.space_before > 0.0 { + let _ = write!(ppr, "", (pp.space_before * k * 100.0).round() as i64); + } + if pp.space_after > 0.0 { + let _ = write!(ppr, "", (pp.space_after * k * 100.0).round() as i64); + } + ppr.push_str(""); + s.push_str(""); + s.push_str(&ppr); + let mut last_size = size; + for l in &lines { + let ol = out_line(ex, cs, l, story); + let mut runs: Vec = Vec::new(); + for mut r in ol.runs { + // PowerPoint sets the spacing; only tracking-sized adjustments would survive, and + // they vary line to line, so none is kept. + r.spc = 0.0; + match runs.last_mut() { + Some(p) + if p.style == r.style + && p.face.get().id() == r.face.get().id() + && (p.size - r.size).abs() < 0.005 + && (p.y - r.y).abs() < 0.01 => + { + p.text.push_str(&r.text) + } + _ => runs.push(r), + } + } + // Composer hyphens at line ends go: PowerPoint breaks the lines itself. + if l.hyphenated + && let Some(r) = runs.last_mut() + && r.text.ends_with(['-', '\u{2010}']) + { + r.text.pop(); + } + if ol.trailing_space + && let Some(r) = runs.last_mut() + && !r.text.ends_with(char::is_whitespace) + { + r.text.push(' '); + } + for run in &runs { + let _ = write!(s, "{}{}", rpr(ex, Some(run), run.size, k, opacity, "a:rPr"), esc(&run.text)); + last_size = run.size; + } + } + s.push_str(&rpr(ex, None, last_size, k, opacity, "a:endParaRPr")); + s.push_str(""); + first = false; + } + s.push_str(""); + ex.shape(it, xf, opacity, Some(&s)); +} diff --git a/crates/pptx/src/xml.rs b/crates/pptx/src/xml.rs new file mode 100644 index 00000000..4eba791d --- /dev/null +++ b/crates/pptx/src/xml.rs @@ -0,0 +1,75 @@ +//! XML text helpers and DrawingML units. + +/// English Metric Units per point. +pub const EMU_PER_PT: f64 = 12_700.0; + +/// Points → EMU, saturating (input-derived geometry may be huge or not finite). +pub fn emu(pt: f64) -> i64 { + if !pt.is_finite() { + return 0; + } + (pt * EMU_PER_PT).round().clamp(-27_273_042_316_900.0, 27_273_042_316_900.0) as i64 +} + +/// A non-negative extent in EMU (DrawingML rejects negative sizes). +pub fn emu_len(pt: f64) -> i64 { + emu(pt).max(0) +} + +/// Degrees → DrawingML angle units (60 000ths of a degree), normalised to 0..360°. +pub fn angle(deg: f64) -> i64 { + if !deg.is_finite() { + return 0; + } + (deg.rem_euclid(360.0) * 60_000.0).round() as i64 % 21_600_000 +} + +/// A 0..1 fraction → DrawingML percentage units (1000ths of a percent). +pub fn pct(v: f64) -> i64 { + if !v.is_finite() { + return 0; + } + (v * 100_000.0).round().clamp(-100_000_000.0, 100_000_000.0) as i64 +} + +/// Escape text for element content and attribute values. Characters XML 1.0 can't hold are +/// dropped. +pub fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + '\'' => o.push_str("'"), + '\t' | '\n' | '\r' => o.push(c), + c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {} + c => o.push(c), + } + } + o +} + +/// sRGB 0..1 → `RRGGBB`. +pub fn hex(rgb: [f32; 3]) -> String { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + format!("{:02X}{:02X}{:02X}", q(rgb[0]), q(rgb[1]), q(rgb[2])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn units() { + assert_eq!(emu(1.0), 12_700); + assert_eq!(emu(f64::NAN), 0); + assert_eq!(emu_len(-3.0), 0); + assert_eq!(angle(-90.0), 16_200_000); + assert_eq!(angle(360.0), 0); + assert_eq!(pct(0.5), 50_000); + assert_eq!(esc("a, &str)] = &[ ), ("app.exportFixedEpub", "Export EPUB (Fixed Layout)…", None, "{path?} — pre-paginated EPUB 3"), ("app.exportHtml", "Export HTML…", None, "{path?} — one self-contained web page"), + ("app.exportPptx", "Export PowerPoint…", None, "{path?} — every page as a slide of editable shapes, pictures and text"), ("app.exportXml", "Export XML…", None, "{path?} — the tagged content"), ("app.printBooklet", "Print Booklet…", None, "{path?} — saddle-stitched printer spreads as PDF"), ("app.qrCode", "Generate QR Code…", None, "{} — the QR Code dialog (object.qrCode does the work)"), @@ -180,6 +181,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:app.exportFixedEpub", "ui:app.exportInteractivePdf", "ui:app.exportHtml", + "ui:app.exportPptx", "ui:app.exportXml", "ui:app.exportText", ">Export", @@ -753,6 +755,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option export_bytes(app, p, "epub", "file.exportFixedEpub"), "app.exportHtml" => export_bytes(app, p, "html", "file.exportHtml"), + "app.exportPptx" => export_bytes(app, p, "pptx", "file.exportPptx"), "app.exportXml" => export_bytes(app, p, "xml", "file.exportXml"), "app.printBooklet" => export_bytes(app, p, "pdf", "file.printBooklet"), "app.menus" => { diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 3e9e28c0..a95f3411 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -43,6 +43,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("pdf", Class::Layer(3)), ("idml", Class::Layer(3)), ("epub", Class::Layer(3)), + ("pptx", Class::Layer(3)), ("textimport", Class::Layer(3)), ("format", Class::Layer(3)), ("tools", Class::Layer(4)), From a075efccd8ab1eccb9db12e48c9bbca2309b974d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Thu, 8 Oct 2026 23:38:08 +0200 Subject: [PATCH 002/298] chore: add contributor name for PhilippKrueger --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db20..fe264b64 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -21,3 +21,6 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false + +[people.PhilippKrueger] +real_name = "Philipp Krüger" From fde78978a49224de25fe78eb9d18c3c254d9f1b3 Mon Sep 17 00:00:00 2001 From: zydvc26s67-web Date: Thu, 8 Oct 2026 14:55:11 -0700 Subject: [PATCH 003/298] Paragraph Styles: wire save and delete into the UI (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine already supported style.paragraph.edit and style.paragraph.delete, but the UI never exposed a delete action and edit was only reachable by double-clicking a style row, with no visible affordance. - Add "Edit Paragraph Style…" and "Delete Paragraph Style…" entries to the paragraph style row context menu (hidden for built-in styles). - Add a new deleteParagraphStyle confirmation dialog that detects whether the style is currently in use and, if so, lets the user pick a replacement style (defaulting to [Basic Paragraph]) for affected text before deleting. - Add engine tests covering paragraph style edit (attribute merge and rename propagation), based-on cycle rejection, delete with text reassignment, and rejection of built-in style deletion. - Add UI-level tests covering the edit dialog persisting changes and the delete dialog removing a style and reassigning text, including the default replacement style. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/engine/src/tests.rs | 54 ++++++++++++++++ crates/ui-egui/src/dialogs.rs | 98 +++++++++++++++++++++++++++++ crates/ui-egui/src/panels/styles.rs | 11 ++++ 3 files changed, 163 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8f2f39b0..9eeb2ae0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -537,3 +537,57 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn paragraph_style_edit_persists_attrs_and_rename() { + let mut s = session(); + let name = + s.execute("style.paragraph.create", &json!({"name": "Body Copy", "para": {"align": "left"}})).unwrap()["name"].as_str().unwrap().to_string(); + s.execute("style.paragraph.edit", &json!({"name": name, "para": {"align": "center"}})).unwrap(); + { + let st = s.doc().unwrap(); + let style = st.doc.styles.para(&name).expect("style still exists"); + assert_eq!(style.para.align, Some(designcraft_doc::Align::Center), "edited attribute persisted"); + } + // Renaming updates the style definition itself, and every place it's referenced. + s.execute("style.paragraph.edit", &json!({"name": name, "rename": "Body Text"})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.styles.para("Body Text").is_some(), "renamed style exists under the new name"); + assert!(st.doc.styles.para(&name).is_none(), "old name is gone"); +} + +#[test] +fn paragraph_style_edit_rejects_based_on_cycle() { + let mut s = session(); + let a = s.execute("style.paragraph.create", &json!({"name": "A"})).unwrap()["name"].as_str().unwrap().to_string(); + let b = s.execute("style.paragraph.create", &json!({"name": "B", "basedOn": a})).unwrap()["name"].as_str().unwrap().to_string(); + let r = s.execute("style.paragraph.edit", &json!({"name": a, "basedOn": b})); + assert!(r.is_err(), "A based on B based on A must be rejected"); +} + +#[test] +fn paragraph_style_delete_reassigns_affected_text() { + let mut s = session(); + let name = s.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string(); + let sid = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "Hello, world"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": name})).unwrap(); + { + let st = s.doc().unwrap(); + let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists"); + assert_eq!(story.paras[0].style, name, "style applied before delete"); + } + s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": designcraft_doc::BASIC_PARAGRAPH})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.styles.para(&name).is_none(), "deleted style is gone"); + let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists"); + assert_eq!(story.paras[0].style, designcraft_doc::BASIC_PARAGRAPH, "affected paragraph reassigned to the replacement style"); +} + +#[test] +fn paragraph_style_delete_rejects_builtin_styles() { + let mut s = session(); + let r = s.execute("style.paragraph.delete", &json!({"name": designcraft_doc::BASIC_PARAGRAPH})); + assert!(r.is_err(), "built-in styles can't be deleted"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 34832db1..b94c6444 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -813,6 +813,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"), "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), "paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"), + "deleteParagraphStyle" => crate::i18n::tr(&app.ui.language, "Delete Paragraph Style"), "footnoteOptions" => crate::i18n::tr(&app.ui.language, "Footnote Options"), "insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"), "findFont" => crate::i18n::tr(&app.ui.language, "Find/Replace Font"), @@ -1053,6 +1054,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } } "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), + "deleteParagraphStyle" => delete_paragraph_style(app, ui, &mut d), "footnoteOptions" => footnote_options(app, ui, &mut d), "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), @@ -1482,6 +1484,14 @@ pub fn confirm(app: &mut DesignApp) -> Result { } Ok(r) } + "deleteParagraphStyle" => { + let name = d.s("name"); + let replace = d.s("replaceWith"); + app.run( + "style.paragraph.delete", + json!({"name": name, "replaceWith": if replace.is_empty() { json!(designcraft_doc::BASIC_PARAGRAPH) } else { json!(replace) }}), + ) + } "colorPicker" => { let hex = d.s("hex"); app.run("object.color", json!({"color": hex, "target": d.s("target")})) @@ -2170,6 +2180,51 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); } +fn delete_paragraph_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let name = d.s("name"); + ui.set_min_width(380.0); + crate::rtl::label( + ui, + egui::RichText::new( + crate::i18n::tr(&app.ui.language, "Delete the paragraph style \"{name}\"?") + .replace("{name}", crate::i18n::style_name(&app.ui.language, &name)), + ) + .size(12.5), + ); + ui.add_space(8.0); + let Some(st) = app.session.active() else { return }; + let in_use = st.doc.stories.values().any(|s| s.paras.iter().any(|f| f.style == name)); + let names: Vec = st.doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| *n != name).collect(); + if in_use { + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "This style is in use. Text using it will be reassigned to:")) + .size(11.0) + .color(crate::theme::Tokens::get(ui.ctx()).text_dim), + ); + } else { + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace with:")).size(11.0).color(crate::theme::Tokens::get(ui.ctx()).text_dim), + ); + } + ui.add_space(4.0); + if !d.fields.contains_key("replaceWith") { + d.fields.insert("replaceWith".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + } + let replace = d.s("replaceWith"); + egui::ComboBox::from_id_salt("del_para_replace") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace))) + .width(260.0) + .show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == replace, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() { + d.fields.insert("replaceWith".into(), json!(n)); + } + } + }); +} + fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { let cur = d.s(key); let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string(); @@ -2681,4 +2736,47 @@ mod tests { let _ = command_fields(c.params); } } + + fn test_app() -> crate::DesignApp { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 1})).unwrap(); + app + } + + #[test] + fn paragraph_style_options_dialog_saves_edits() { + let mut app = test_app(); + let name = app.session.execute("style.paragraph.create", &json!({"name": "Body Copy"})).unwrap()["name"].as_str().unwrap().to_string(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": name, "p.align": "center"}))); + confirm(&mut app).expect("OK saves the style edit"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.styles.para(&name).unwrap().para.align, Some(designcraft_doc::Align::Center)); + } + + #[test] + fn delete_paragraph_style_dialog_removes_the_style_and_reassigns_text() { + let mut app = test_app(); + let name = app.session.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string(); + let sid = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap(); + app.session.execute("text.insert", &json!({"text": "Hello"})).unwrap(); + app.session.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + app.session.execute("style.paragraph.apply", &json!({"name": name})).unwrap(); + app.ui.dialog = Some(Dialog::new("deleteParagraphStyle", json!({"name": name, "replaceWith": designcraft_doc::BASIC_PARAGRAPH}))); + confirm(&mut app).expect("OK deletes the style"); + let st = app.session.active().unwrap(); + assert!(st.doc.styles.para(&name).is_none(), "style removed"); + let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).unwrap(); + assert_eq!(story.paras[0].style, designcraft_doc::BASIC_PARAGRAPH, "text reassigned to the replacement style"); + } + + #[test] + fn delete_paragraph_style_dialog_defaults_replacement_to_basic_paragraph() { + let mut app = test_app(); + let name = app.session.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string(); + // No `replaceWith` set — the dialog's default field. + app.ui.dialog = Some(Dialog::new("deleteParagraphStyle", json!({"name": name}))); + confirm(&mut app).expect("OK deletes the style with the default replacement"); + let st = app.session.active().unwrap(); + assert!(st.doc.styles.para(&name).is_none()); + } } diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index c3412095..2f41f742 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -152,5 +152,16 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren ui.data_mut(|d| d.insert_temp(id, name)); }); } + if para && !n.starts_with('[') { + ui.separator(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Edit Paragraph Style…"))).clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", json!({"name": n}))); + ui.close(); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Paragraph Style…"))).clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("deleteParagraphStyle", json!({"name": n}))); + ui.close(); + } + } }); } From 3018c56e0c8428fead12d0e72e1de9160579dbd4 Mon Sep 17 00:00:00 2001 From: zydvc26s67-web Date: Thu, 8 Oct 2026 17:01:14 -0700 Subject: [PATCH 004/298] Character Styles: wire save and delete into the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Paragraph Styles fix (#169): Character Styles had no save or delete affordance at all — not even an edit dialog existed. - Add a new characterStyleOptions dialog (General: rename/based-on, Basic Character Formats, Character Color) reachable by double-click or the row's new "Edit Character Style…" context-menu entry. - Add a style.character.delete engine command (mirrors style.paragraph.delete): rejects built-in styles, reassigns any text runs using the deleted style to a replacement (default [None]). - Add rename and basedOn (with cycle rejection via the existing char_based_on_cycles) support to style.character.edit, matching style.paragraph.edit. - Add a deleteCharacterStyle confirmation dialog with the same in-use detection and replacement-style picker as the paragraph one, wired via a new "Delete Character Style…" context-menu entry. - Add Styles::resolve_char_style to resolve a character style's effective attributes for display, mirroring resolve_para_style. - Add engine tests (edit persistence/rename, based-on cycle rejection, delete with run reassignment, built-in rejection) and UI-level dialog tests for both new dialogs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/doc/src/styles.rs | 5 + crates/engine/src/cmd/style.rs | 38 +++++ crates/engine/src/tests.rs | 57 +++++++ crates/ui-egui/src/dialogs.rs | 231 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/styles.rs | 13 ++ 5 files changed, 344 insertions(+) diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 478cf045..932da5ce 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -336,6 +336,11 @@ impl Styles { cp } + /// Character-style-only resolution (no paragraph base, no local overrides). + pub fn resolve_char_style(&self, name: &str) -> CharProps { + self.resolve_char(&CharProps::default(), &CharFormat { style: name.into(), over: CharAttrs::default() }) + } + /// Would making `name` based on `parent` create a cycle? pub fn para_based_on_cycles(&self, name: &str, parent: &str) -> bool { name == parent || self.para_chain(parent).iter().any(|s| s.name == name) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 3fb86fb6..d1ffe2b4 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -102,6 +102,7 @@ pub fn specs() -> Vec { cmd!("style.paragraph.delete", "Delete Paragraph Style", [], None, "{name, replaceWith?}", has_doc, delete_para), cmd!("style.character.create", "New Character Style…", [], None, "{name, basedOn?, chars?: {…}}", has_doc, create_char), cmd!("style.character.edit", "Character Style Options…", [], None, "{name, rename?, basedOn?, chars?}", has_doc, edit_char), + cmd!("style.character.delete", "Delete Character Style", [], None, "{name, replaceWith?}", has_doc, delete_char), cmd!(query "style.list", "List Styles", [], None, "{} → paragraph and character style names", has_doc, |s, _| { let st = s.doc()?; Ok(json!({ @@ -594,9 +595,46 @@ fn create_char(s: &mut Session, p: &Value) -> Result { fn edit_char(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.character.edit", "missing name"))?.to_string(); let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + let rename = str_param(p, "rename").map(str::to_string); + let based = p.get("basedOn").cloned(); s.edit(|d, _| { + if let Some(Value::String(b)) = &based + && d.styles.char_based_on_cycles(&name, b) + { + return Err(bad("style.character.edit", "based-on would create a cycle")); + } let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", "no such style"))?; st.chars.merge(&chars); + match based { + Some(Value::String(b)) => st.based_on = Some(b), + Some(Value::Null) => st.based_on = None, + _ => {} + } + if let Some(n) = rename.clone() { + // Every use: other styles, stories, nested/GREP styles, export tags. + rename_style(d, false, &name, &n); + } + ok() + }) +} + +fn delete_char(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").unwrap_or("").to_string(); + let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string(); + if name.starts_with('[') { + return Err(bad("style.character.delete", "built-in styles can't be deleted")); + } + s.edit(|d, _| { + d.styles_mut().character.retain(|x| x.name != name); + for sid in d.stories.keys().copied().collect::>() { + if let Some(story) = d.story_mut(sid) { + story.for_each_text_mut(&mut |st| { + for r in st.chars.iter_mut().filter(|r| r.format.style == name) { + r.format.style = repl.clone(); + } + }); + } + } ok() }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9eeb2ae0..692cb926 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -591,3 +591,60 @@ fn paragraph_style_delete_rejects_builtin_styles() { let r = s.execute("style.paragraph.delete", &json!({"name": designcraft_doc::BASIC_PARAGRAPH})); assert!(r.is_err(), "built-in styles can't be deleted"); } + +#[test] +fn character_style_edit_persists_attrs_and_rename() { + let mut s = session(); + let name = s.execute("style.character.create", &json!({"name": "Key Term", "chars": {"fontStyle": "Bold"}})).unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + s.execute("style.character.edit", &json!({"name": name, "chars": {"tracking": 20.0}})).unwrap(); + { + let st = s.doc().unwrap(); + let style = st.doc.styles.char_style(&name).expect("style still exists"); + assert_eq!(style.chars.tracking, Some(20.0), "edited attribute persisted"); + } + // Renaming updates the style definition itself, and every place it's referenced. + s.execute("style.character.edit", &json!({"name": name, "rename": "Emphasis"})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.styles.char_style("Emphasis").is_some(), "renamed style exists under the new name"); + assert!(st.doc.styles.char_style(&name).is_none(), "old name is gone"); +} + +#[test] +fn character_style_edit_rejects_based_on_cycle() { + let mut s = session(); + let a = s.execute("style.character.create", &json!({"name": "A"})).unwrap()["name"].as_str().unwrap().to_string(); + let b = s.execute("style.character.create", &json!({"name": "B", "basedOn": a})).unwrap()["name"].as_str().unwrap().to_string(); + let r = s.execute("style.character.edit", &json!({"name": a, "basedOn": b})); + assert!(r.is_err(), "A based on B based on A must be rejected"); +} + +#[test] +fn character_style_delete_reassigns_affected_text() { + let mut s = session(); + let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "Hello, world"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + s.execute("style.character.apply", &json!({"name": name})).unwrap(); + { + let st = s.doc().unwrap(); + let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists"); + assert_eq!(story.chars[0].format.style, name, "style applied before delete"); + } + s.execute("style.character.delete", &json!({"name": name, "replaceWith": designcraft_doc::NO_CHAR_STYLE})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.styles.char_style(&name).is_none(), "deleted style is gone"); + let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists"); + assert_eq!(story.chars[0].format.style, designcraft_doc::NO_CHAR_STYLE, "affected run reassigned to the replacement style"); +} + +#[test] +fn character_style_delete_rejects_builtin_styles() { + let mut s = session(); + let r = s.execute("style.character.delete", &json!({"name": designcraft_doc::NO_CHAR_STYLE})); + assert!(r.is_err(), "built-in styles can't be deleted"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b94c6444..646e80dc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -53,6 +53,9 @@ impl Dialog { if id == "paragraphStyleOptions" { fields.entry("section".to_string()).or_insert(json!("general")); } + if id == "characterStyleOptions" { + fields.entry("section".to_string()).or_insert(json!("general")); + } if id == "frameSize" { for k in ["width", "height"] { if let Some(v) = fields.get(k).and_then(Value::as_f64) { @@ -814,6 +817,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), "paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"), "deleteParagraphStyle" => crate::i18n::tr(&app.ui.language, "Delete Paragraph Style"), + "characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"), + "deleteCharacterStyle" => crate::i18n::tr(&app.ui.language, "Delete Character Style"), "footnoteOptions" => crate::i18n::tr(&app.ui.language, "Footnote Options"), "insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"), "findFont" => crate::i18n::tr(&app.ui.language, "Find/Replace Font"), @@ -1055,6 +1060,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), "deleteParagraphStyle" => delete_paragraph_style(app, ui, &mut d), + "characterStyleOptions" => character_style_options(app, ui, &mut d), + "deleteCharacterStyle" => delete_character_style(app, ui, &mut d), "footnoteOptions" => footnote_options(app, ui, &mut d), "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), @@ -1492,6 +1499,33 @@ pub fn confirm(app: &mut DesignApp) -> Result { json!({"name": name, "replaceWith": if replace.is_empty() { json!(designcraft_doc::BASIC_PARAGRAPH) } else { json!(replace) }}), ) } + "characterStyleOptions" => { + let name = d.s("name"); + let mut chars = serde_json::Map::new(); + for (k, v) in &d.fields { + if let Some(a) = k.strip_prefix("c.") { + chars.insert(a.into(), v.clone()); + } + } + let mut params = json!({"name": name, "chars": chars}); + let based = d.s("basedOn"); + if !based.is_empty() { + params["basedOn"] = if based == designcraft_doc::NO_CHAR_STYLE { Value::Null } else { json!(based) }; + } + let rename = d.s("rename"); + if !rename.is_empty() && rename != name { + params["rename"] = json!(rename); + } + app.run("style.character.edit", params) + } + "deleteCharacterStyle" => { + let name = d.s("name"); + let replace = d.s("replaceWith"); + app.run( + "style.character.delete", + json!({"name": name, "replaceWith": if replace.is_empty() { json!(designcraft_doc::NO_CHAR_STYLE) } else { json!(replace) }}), + ) + } "colorPicker" => { let hex = d.s("hex"); app.run("object.color", json!({"color": hex, "target": d.s("target")})) @@ -2225,6 +2259,166 @@ fn delete_paragraph_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog }); } +fn character_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let name = d.s("name"); + let Some(st) = app.session.active() else { return }; + let Some(style) = st.doc.styles.char_style(&name).cloned() else { + ui.label(format!("No style named {name}")); + return; + }; + let names: Vec = st.doc.styles.character.iter().map(|c| c.name.clone()).filter(|n| *n != name).collect(); + let cp = st.doc.styles.resolve_char_style(&name); + let cv = serde_json::to_value(&cp).unwrap_or_default(); + let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone()); + ui.set_min_width(480.0); + ui.horizontal_top(|ui| { + ui.set_min_height(280.0); + ui.set_max_height(280.0); + ui.vertical(|ui| { + ui.set_width(150.0); + for (id, label) in [("general", "General"), ("chars", "Basic Character Formats"), ("color", "Character Color")] { + if ui.selectable_label(d.s("section") == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + d.fields.insert("section".into(), json!(id)); + } + } + }); + ui.separator(); + ui.vertical(|ui| match d.s("section").as_str() { + "chars" => { + let fonts = crate::panels::fonts(app); + let menu = crate::panels::font_menu(app); + egui::Grid::new("csc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:")); + let fam = cur(d, "c.fontFamily", &cv["fontFamily"]).as_str().unwrap_or("").to_string(); + egui::ComboBox::from_id_salt("csfam").selected_text(crate::panels::font_label(app, &menu, &fam)).width(200.0).show_ui(ui, |ui| { + if let Some(f) = crate::panels::font_menu_rows(app, ui, &menu, &fam) { + d.fields.insert("c.fontFamily".into(), json!(f)); + } + }); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Style:")); + let sty = cur(d, "c.fontStyle", &cv["fontStyle"]).as_str().unwrap_or("").to_string(); + egui::ComboBox::from_id_salt("cssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| { + for s in fonts.styles(&fam) { + if ui.selectable_label(s == sty, &s).clicked() { + d.fields.insert("c.fontStyle".into(), json!(s)); + } + } + }); + ui.end_row(); + for (label, key, suffix) in [("Size:", "size", " pt"), ("Tracking:", "tracking", "")] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); + let v = cur(d, &format!("c.{key}"), &cv[key]).as_f64(); + if let Some(n) = crate::widgets::number(ui, &format!("cs{key}"), v, suffix, 80.0, 2) { + d.fields.insert(format!("c.{key}"), json!(n)); + } + ui.end_row(); + } + }); + } + "color" => { + let cur_fill = cur(d, "c.fill", &cv["fill"]).as_str().unwrap_or("").to_string(); + let swatches: Vec = st.doc.swatches.iter().map(|s| s.name.clone()).collect(); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + for sw in swatches { + let (c, g) = crate::widgets::swatch_colors(&st.doc, &sw, 1.0); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); + crate::widgets::paint_chip(ui.painter(), r, c, g); + if ui.selectable_label(sw == cur_fill, &sw).clicked() { + d.fields.insert("c.fill".into(), json!(sw)); + } + }); + } + }); + } + _ => { + egui::Grid::new("csg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Style Name:")); + if !d.fields.contains_key("rename") { + d.fields.insert("rename".into(), json!(name)); + } + let mut rn = d.s("rename"); + if ui.add(egui::TextEdit::singleline(&mut rn).desired_width(220.0)).changed() { + d.fields.insert("rename".into(), json!(rn)); + } + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Based On:")); + let based = d + .fields + .get("basedOn") + .and_then(Value::as_str) + .map(str::to_string) + .or(style.based_on.clone()) + .unwrap_or_else(|| designcraft_doc::NO_CHAR_STYLE.into()); + egui::ComboBox::from_id_salt("csbased") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &based))) + .width(220.0) + .show_ui(ui, |ui| { + for n in [designcraft_doc::NO_CHAR_STYLE].into_iter().chain(names.iter().map(String::as_str)) { + if ui.selectable_label(n == based, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() { + d.fields.insert("basedOn".into(), json!(n)); + } + } + }); + ui.end_row(); + }); + ui.add_space(8.0); + crate::rtl::label( + ui, + egui::RichText::new(format!("{} {} {:.1} pt", cp.font_family, cp.font_style, cp.size)) + .color(crate::theme::Tokens::get(ui.ctx()).text_dim), + ); + } + }); + }); +} + +fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let name = d.s("name"); + ui.set_min_width(380.0); + crate::rtl::label( + ui, + egui::RichText::new( + crate::i18n::tr(&app.ui.language, "Delete the character style \"{name}\"?") + .replace("{name}", crate::i18n::style_name(&app.ui.language, &name)), + ) + .size(12.5), + ); + ui.add_space(8.0); + let Some(st) = app.session.active() else { return }; + let in_use = st.doc.stories.values().any(|s| s.chars.iter().any(|r| r.format.style == name)); + let names: Vec = st.doc.styles.character.iter().map(|c| c.name.clone()).filter(|n| *n != name).collect(); + if in_use { + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "This style is in use. Text using it will be reassigned to:")) + .size(11.0) + .color(crate::theme::Tokens::get(ui.ctx()).text_dim), + ); + } else { + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace with:")).size(11.0).color(crate::theme::Tokens::get(ui.ctx()).text_dim), + ); + } + ui.add_space(4.0); + if !d.fields.contains_key("replaceWith") { + d.fields.insert("replaceWith".into(), json!(designcraft_doc::NO_CHAR_STYLE)); + } + let replace = d.s("replaceWith"); + egui::ComboBox::from_id_salt("del_char_replace") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace))) + .width(260.0) + .show_ui(ui, |ui| { + for n in [designcraft_doc::NO_CHAR_STYLE].into_iter().chain(names.iter().map(String::as_str)) { + if ui.selectable_label(n == replace, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() { + d.fields.insert("replaceWith".into(), json!(n)); + } + } + }); +} + fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { let cur = d.s(key); let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string(); @@ -2779,4 +2973,41 @@ mod tests { let st = app.session.active().unwrap(); assert!(st.doc.styles.para(&name).is_none()); } + + #[test] + fn character_style_options_dialog_saves_edits() { + let mut app = test_app(); + let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string(); + app.ui.dialog = Some(Dialog::new("characterStyleOptions", json!({"name": name, "c.tracking": 20.0}))); + confirm(&mut app).expect("OK saves the style edit"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.styles.char_style(&name).unwrap().chars.tracking, Some(20.0)); + } + + #[test] + fn delete_character_style_dialog_removes_the_style_and_reassigns_text() { + let mut app = test_app(); + let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string(); + let sid = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap(); + app.session.execute("text.insert", &json!({"text": "Hello"})).unwrap(); + app.session.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + app.session.execute("style.character.apply", &json!({"name": name})).unwrap(); + app.ui.dialog = Some(Dialog::new("deleteCharacterStyle", json!({"name": name, "replaceWith": designcraft_doc::NO_CHAR_STYLE}))); + confirm(&mut app).expect("OK deletes the style"); + let st = app.session.active().unwrap(); + assert!(st.doc.styles.char_style(&name).is_none(), "style removed"); + let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).unwrap(); + assert_eq!(story.chars[0].format.style, designcraft_doc::NO_CHAR_STYLE, "text reassigned to the replacement style"); + } + + #[test] + fn delete_character_style_dialog_defaults_replacement_to_none() { + let mut app = test_app(); + let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string(); + // No `replaceWith` set — the dialog's default field. + app.ui.dialog = Some(Dialog::new("deleteCharacterStyle", json!({"name": name}))); + confirm(&mut app).expect("OK deletes the style with the default replacement"); + let st = app.session.active().unwrap(); + assert!(st.doc.styles.char_style(&name).is_none()); + } } diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 2f41f742..c44ff488 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -98,6 +98,8 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren ); if resp.double_clicked() && para { app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", json!({"name": n}))); + } else if resp.double_clicked() && !n.starts_with('[') { + app.ui.dialog = Some(crate::dialogs::Dialog::new("characterStyleOptions", json!({"name": n}))); } else if resp.clicked() { let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" }; let clear = ui.input(|i| i.modifiers.alt); @@ -163,5 +165,16 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren ui.close(); } } + if !para && !n.starts_with('[') { + ui.separator(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Edit Character Style…"))).clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("characterStyleOptions", json!({"name": n}))); + ui.close(); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Character Style…"))).clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("deleteCharacterStyle", json!({"name": n}))); + ui.close(); + } + } }); } From 9d986e4ea70f185324439c72acb65237a79aff25 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 08:09:37 +0000 Subject: [PATCH 005/298] Warichu: hostile line spacing and break minimums can't break layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document's warichu line spacing was used unclamped, so 1e308 (IDML or type.char) put an infinite coordinate into the layout and the PDF; it is clamped to ±16 em and a non-finite value counts as 0. The break minimums' sum could overflow on 32-bit targets (wasm) and now saturates. --- crates/compose/src/tests.rs | 21 +++++++++++++++++++++ crates/compose/src/warichu.rs | 19 +++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index dea9008d..4301d9ff 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1095,6 +1095,27 @@ fn warichu_stacks_the_run_inside_the_line_and_closes_up() { assert!((l.end_x - (z.x + z.adv)).abs() < 1e-3, "the line-end caret follows the close-up"); } +#[test] +fn warichu_line_spacing_from_a_document_stays_finite() { + for spacing in [1e308, -1e308, f64::INFINITY, f64::NAN] { + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, "ABCDEFGHZ", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(0..8, |f| { + f.over.warichu = Some(true); + f.over.warichu_lines = Some(16); + f.over.warichu_line_spacing = Some(spacing); + }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + assert!( + l.glyphs.iter().all(|g| g.x.is_finite() && g.y.is_finite() && g.y.abs() < 1e4), + "{spacing}: {:?}", + l.glyphs.iter().map(|g| g.y).collect::>() + ); + } +} + #[test] fn warichu_break_minimum_keeps_a_short_run_on_one_line() { let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); diff --git a/crates/compose/src/warichu.rs b/crates/compose/src/warichu.rs index aaabb2e5..133e392e 100644 --- a/crates/compose/src/warichu.rs +++ b/crates/compose/src/warichu.rs @@ -84,7 +84,8 @@ fn line_count(len: usize, want: usize, before: usize, after: usize) -> usize { let before = before.max(1); let after = after.max(1); while n >= 2 { - if before + after + n - 2 <= len { + // The minimums come from documents and commands: on 32-bit targets their sum can overflow. + if before.saturating_add(after).saturating_add(n - 2) <= len { return n; } n -= 1; @@ -220,7 +221,9 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f let small = st.size * scale; // 0 is the default (one small em). Any other value, including a negative one, is added to // that em and then clamped so the rows cannot swap order. - let gap = if st.warichu_line_spacing == 0.0 { small } else { (small + st.warichu_line_spacing).max(0.0) }; + // Bounded, so a hostile value can't move a row to an infinite or huge coordinate. + let spacing = if st.warichu_line_spacing.is_finite() { st.warichu_line_spacing.clamp(-16.0 * st.size, 16.0 * st.size) } else { 0.0 }; + let gap = if spacing == 0.0 { small } else { (small + spacing).max(0.0) }; let mut off = 0_usize; for (li, &c) in counts.iter().enumerate() { let w = widths[li]; @@ -264,3 +267,15 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f } delta } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn huge_break_minimums_keep_one_line() { + assert_eq!(line_count(10, 2, usize::MAX, 1), 1); + assert_eq!(line_count(10, 3, 1, usize::MAX), 1); + assert_eq!(line_count(10, 2, 3, 3), 2); + } +} From 71d33cd02feaac042d15dcb6d0f1bd29d257026c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 08:35:15 +0000 Subject: [PATCH 006/298] IDML: text inside a paragraph-level cross-reference source keeps its formatting --- crates/idml/src/import.rs | 27 +++++++++++++++++---------- crates/idml/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index fb237844..c18085cd 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1658,6 +1658,15 @@ impl<'r> Importer<'r> { t } + /// The format of the text in a `CharacterStyleRange`: its style, and its local attributes over + /// the enclosing paragraph range's. + fn range_format(&mut self, c: &El, pchars: &CharAttrs) -> CharFormat { + let style = c.get("AppliedCharacterStyle").map(|r| self.char_style_ref(r)).unwrap_or_else(|| st::NO_CHAR_STYLE.into()); + let mut over = pchars.clone(); + over.merge(&self.char_attrs(c)); + CharFormat { style, over } + } + fn walk_story(&mut self, e: &El, b: &mut StoryBuilder, pf: &ParaFormat, pchars: &CharAttrs, cf: &CharFormat, brk: Option<&str>) { for n in &e.children { match n { @@ -1672,13 +1681,13 @@ impl<'r> Importer<'r> { { *last = npf.clone(); } - self.walk_story(c, b, &npf, &chars, cf, None); + // Text and markers outside any character range (a destination or a + // cross-reference source at paragraph level) take the paragraph's format. + let pcf = CharFormat { style: st::NO_CHAR_STYLE.into(), over: chars.clone() }; + self.walk_story(c, b, &npf, &chars, &pcf, None); } "CharacterStyleRange" => { - let style = c.get("AppliedCharacterStyle").map(|r| self.char_style_ref(r)).unwrap_or_else(|| st::NO_CHAR_STYLE.into()); - let mut over = pchars.clone(); - over.merge(&self.char_attrs(c)); - let ncf = CharFormat { style, over }; + let ncf = self.range_format(c, pchars); let bt = c.get("ParagraphBreakType").filter(|t| *t != "Anywhere"); b.last = ncf.clone(); self.walk_story(c, b, pf, pchars, &ncf, bt); @@ -1742,11 +1751,9 @@ impl<'r> Importer<'r> { "CrossReferenceSource" => { // The generated text is regenerated from the format. b.xrefs.push((c.get("Self").unwrap_or("").to_string(), c.get("AppliedFormat").unwrap_or("").to_string())); - let inner = c.find("CharacterStyleRange"); - let f = match inner.and_then(|r| r.get("AppliedCharacterStyle")) { - Some(r) => CharFormat { style: self.char_style_ref(r), over: pchars.clone() }, - None => cf.clone(), - }; + // A source at paragraph level wraps a range; inside a range it holds the + // text directly and takes that range's format. + let f = c.find("CharacterStyleRange").map_or_else(|| cf.clone(), |r| self.range_format(r, pchars)); b.push(&designcraft_doc::XREF_MARK.to_string(), &f); } "Footnote" => { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 163fd0b2..fad724a0 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -652,6 +652,44 @@ fn round_trips_cross_references() { assert_eq!(back.xref_formats.len(), d.xref_formats.len()); } +/// A cross-reference source sits either inside a character range (holding the text directly) or at +/// paragraph level around a range. Both give the generated text the paragraph's and the range's +/// attributes, so both set the same text at the same width. +#[test] +fn cross_reference_source_nesting_keeps_formatting() { + let story = |body: &str| { + format!( + r#" + +{body}"# + ) + }; + const CSR: &str = r#""#; + let inside = story(&format!( + r#"{CSR}See section 11., then a few more words to wrap."# + )); + let around = story(&format!( + r#"{CSR}See section {CSR}11.{CSR}, then a few more words to wrap."# + )); + let compose = |xml: &str| { + let d = import_idml(&fixture_with_story(xml)).unwrap(); + d.check().unwrap(); + let s = d.stories.values().find(|s| !s.xrefs.is_empty()).expect("story with a cross-reference").clone(); + let cs = designcraft_compose::compose_story(&d, s.id, &Default::default()); + let glyphs: Vec<_> = cs.frames.iter().flat_map(|f| &f.lines).flat_map(|l| l.glyphs.iter().map(move |g| (g.byte, g.x, l.baseline))).collect(); + (s, glyphs) + }; + let (a, ga) = compose(&inside); + let (b, gb) = compose(&around); + assert!(ga.len() > a.text.chars().count() / 2, "the story composes"); + assert_eq!(a.text, b.text); + assert_eq!(a.chars, b.chars); + assert_eq!(a.chars.len(), 1, "the generated text shares its neighbours' format"); + assert_eq!(a.chars[0].format.over.tracking, Some(200.0)); + assert_eq!(a.chars[0].format.over.size, Some(11.0)); + assert_eq!(ga, gb); +} + #[test] fn round_trips_index_references() { use designcraft_doc::index::IndexRange; From 8db2f767984106e5a16b0c79a69753b7cb1711bc Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:50:36 +0700 Subject: [PATCH 007/298] fix(geom): keep ruler ticks finite for invalid zoom values Guard non-positive and non-finite viewport scales before the ruler tick spacing loops, and cap extreme magnitudes. Cover invalid and extreme zoom values without changing normal zoom behavior. --- crates/geom/src/units.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs index 7b5aae77..2e138c18 100644 --- a/crates/geom/src/units.rs +++ b/crates/geom/src/units.rs @@ -110,6 +110,10 @@ impl Unit { /// Ruler tick spacing (major, subdivisions) in points for a given zoom (screen px per pt). pub fn ruler_ticks(self, zoom: f64) -> (f64, u32) { + // A non-positive zoom can keep the major-spacing loop growing forever. Clamp + // extreme positive values too, so saved/corrupt viewport state cannot overflow + // the spacing calculation before the view restores a usable zoom. + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom.clamp(1e-6, 1e6) } else { 1.0 }; let (base, subs): (f64, &[u32]) = match self { Unit::Inches | Unit::InchesDecimal => (PT_PER_INCH, &[8, 4, 2]), Unit::Picas => (PT_PER_PICA, &[6, 3, 2]), @@ -357,6 +361,20 @@ mod tests { } } + #[test] + fn ruler_ticks_reject_invalid_zoom_without_hanging_or_overflowing() { + for unit in Unit::ALL { + let fallback = unit.ruler_ticks(1.0); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, -0.0, 0.0] { + assert_eq!(unit.ruler_ticks(bad), fallback, "{unit:?}: {bad}"); + } + for extreme in [f64::MIN_POSITIVE, 1e-300, 1e300, f64::MAX] { + let (major, subdivisions) = unit.ruler_ticks(extreme); + assert!(major.is_finite() && major > 0.0 && subdivisions > 0, "{unit:?}: {extreme}"); + } + } + } + #[test] fn ruler_ticks_are_reasonable() { for u in Unit::ALL { From 2e03e178204ac9cc483140d3b8d9e5683a46911e Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:52:05 +0700 Subject: [PATCH 008/298] fix(layout): reject unsupported spread-view rotation angles Reject non-quarter-turn or non-integer view angles instead of silently truncating or defaulting them. Add a command-level regression test for invalid inputs, valid quarter turns, and clearing the rotation. --- crates/engine/src/cmd/layout.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd532079..bf967483 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -105,7 +105,13 @@ pub fn specs() -> Vec { has_doc, |s, p| { let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - let angle = p.get("angle").and_then(Value::as_i64).unwrap_or(90); + let angle = match p.get("angle") { + None => 90, + Some(v) => v.as_i64().ok_or_else(|| bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees"))?, + }; + if angle % 90 != 0 { + return Err(bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees")); + } s.edit(|d, _| { let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.rotateSpreadView", "no such spread"))?; let pg = std::sync::Arc::make_mut(sp).pages.first_mut().ok_or_else(|| bad("layout.rotateSpreadView", "empty spread"))?; @@ -921,3 +927,27 @@ mod adjust_tests { assert_eq!(b, b2); } } + +#[cfg(test)] +mod spread_rotation_input_tests { + use serde_json::json; + + #[test] + fn quarter_turns_work_and_other_angles_are_rejected_without_mutation() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let rotation = |s: &crate::Session| s.doc().unwrap().doc.spreads[0].pages[0].view_rotation; + for bad in [json!(45), json!(-45), json!(91), json!("90"), json!(0.5)] { + assert!(s.execute("layout.rotateSpreadView", &json!({"angle": bad})).is_err(), "{bad}"); + assert_eq!(rotation(&s), 0, "invalid input must not change the view"); + } + s.execute("layout.rotateSpreadView", &json!({"angle": 90})).unwrap(); + assert_eq!(rotation(&s), 1); + s.execute("layout.rotateSpreadView", &json!({"angle": -90})).unwrap(); + assert_eq!(rotation(&s), 0); + s.execute("layout.rotateSpreadView", &json!({"angle": 180})).unwrap(); + assert_eq!(rotation(&s), 2); + s.execute("layout.rotateSpreadView", &json!({"angle": 0})).unwrap(); + assert_eq!(rotation(&s), 0, "zero resets the spread view"); + } +} From 67fce0f63c13974a9f1d73e9cafa3e208bacc8ef Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:54:14 +0700 Subject: [PATCH 009/298] fix(geom): carry rounded pica and cicero fractions Apply the displayed precision before testing for a 12-point carry, so measurements near a pica/cicero boundary do not display as 0p12 or 0c12. Cover positive, negative, coarse, and subunit rounding. --- crates/geom/src/units.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs index 7b5aae77..0e0c9b4b 100644 --- a/crates/geom/src/units.rs +++ b/crates/geom/src/units.rs @@ -270,7 +270,12 @@ pub fn format_measure_prec(pt: f64, unit: Unit, decimals: usize) -> String { let a = pt.abs(); let mut whole = (a / big).floor(); let mut rest = (a - whole * big) / small; - if (rest - 12.0).abs() < 1e-6 || rest > 12.0 - 1e-6 { + // Carry when the displayed fractional points round up to a full pica/cicero. + // Checking unrounded rest misses values such as 11.9999 at 3 decimals, + // which would otherwise render as the misleading "0p12". + let scale = 10.0_f64.powi(decimals.min(12) as i32); + rest = (rest * scale).round() / scale; + if rest >= 12.0 { whole += 1.0; rest = 0.0; } @@ -335,6 +340,17 @@ mod tests { assert!(parse_measure("50%", Unit::Points).is_err()); } + #[test] + fn pica_and_cicero_rounding_carries_into_the_major_unit() { + assert_eq!(format_measure_prec(11.9999, Unit::Picas, 3), "1p0"); + assert_eq!(format_measure_prec(-11.9999, Unit::Picas, 3), "-1p0"); + assert_eq!(format_measure_prec(11.9994, Unit::Picas, 3), "0p11.999"); + assert_eq!(format_measure_prec(11.6, Unit::Picas, 0), "1p0"); + let almost_cicero = PT_PER_CICERO - 0.0001 * PT_PER_DIDOT; + assert_eq!(format_measure_prec(almost_cicero, Unit::Ciceros, 3), "1c0"); + assert!(close(p(&format_measure_prec(11.9999, Unit::Picas, 3)), 12.0)); + } + #[test] fn formatting() { assert_eq!(format_measure(18.0, Unit::Picas), "1p6"); From 14b506c3730bd64dbe6c3654e0157e9c98f3f338 Mon Sep 17 00:00:00 2001 From: dwetscher <24957205+dwetscher@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:27:50 +0200 Subject: [PATCH 010/298] i18n: translate the whole interface into German A German table (crates/ui-egui/src/i18n/de.rs) covers every key of the shared, Japanese, Arabic, Brazilian Portuguese, Italian and Ukrainian catalogs (1,697 rows; before, 164 strings were German). The shared table's German column stays the fallback for keys added after it. Tests check placeholders, padding, colons and ellipses against the English source. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/i18n.rs | 18 +- crates/ui-egui/src/i18n/de.rs | 1776 +++++++++++++++++++++++++++++++++ 3 files changed, 1791 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/i18n/de.rs diff --git a/ROADMAP.md b/ROADMAP.md index 86a43545..668331c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -110,7 +110,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). - Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `