From a4540c1f0e184bada65da03d51246f94bf641bce Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 11:08:13 -0400 Subject: [PATCH 01/49] Release workflow: pass the Azure signing secrets so Windows builds are signed (#181) Since the release workflow moved to storytold/craft-common-actions, it has passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads values from the release environment, but only for names the caller lists, so the AZURE_* names that Trusted Signing uses came through empty and Windows builds would ship unsigned. Same fix as storytold/craft-launcher#174. Co-authored-by: Claude Opus 5.5 --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15906720..2c402be8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} From 688c7ef592c901ca4541c25e210c1b302e4dc066 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 11:11:13 -0400 Subject: [PATCH 02/49] Release 0.6.1: rebuild with the fixed shared release workflow The previous release build failed in craft-common-actions, not in this repo: riscv64 packaging got --formats deb,rpm,tar (fixed in craft-common-actions#3), Linux jobs lacked the ALSA headers (craft-common-actions#5), and Windows signing needed the AZURE_* secrets listed in the caller. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 54 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6b839d2b..8518da8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -877,7 +877,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "deckcraft" -version = "0.6.0" +version = "0.6.1" dependencies = [ "arboard", "cpal", @@ -896,7 +896,7 @@ dependencies = [ [[package]] name = "deckcraft-anim" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-geom", "deckcraft-model", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "deckcraft-av1" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-bitstream", "rayon", @@ -914,14 +914,14 @@ dependencies = [ [[package]] name = "deckcraft-bitstream" -version = "0.6.0" +version = "0.6.1" dependencies = [ "proptest", ] [[package]] name = "deckcraft-cli" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-engine", "deckcraft-mcp", @@ -930,14 +930,14 @@ dependencies = [ [[package]] name = "deckcraft-color" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde", ] [[package]] name = "deckcraft-engine" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-color", "deckcraft-fonts", @@ -959,7 +959,7 @@ dependencies = [ [[package]] name = "deckcraft-fonts" -version = "0.6.0" +version = "0.6.1" dependencies = [ "epaint_default_fonts", "harfrust 0.13.3", @@ -971,7 +971,7 @@ dependencies = [ [[package]] name = "deckcraft-format" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-model", "serde", @@ -982,7 +982,7 @@ dependencies = [ [[package]] name = "deckcraft-geom" -version = "0.6.0" +version = "0.6.1" dependencies = [ "kurbo", "serde", @@ -990,7 +990,7 @@ dependencies = [ [[package]] name = "deckcraft-h264" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-bitstream", "rayon", @@ -999,7 +999,7 @@ dependencies = [ [[package]] name = "deckcraft-hevc" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1008,7 +1008,7 @@ dependencies = [ [[package]] name = "deckcraft-isobmff" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-bitstream", "thiserror 2.0.21", @@ -1016,11 +1016,11 @@ dependencies = [ [[package]] name = "deckcraft-matroska" -version = "0.6.0" +version = "0.6.1" [[package]] name = "deckcraft-mcp" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-engine", "deckcraft-render", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "deckcraft-media" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-av1", "deckcraft-h264", @@ -1048,7 +1048,7 @@ dependencies = [ [[package]] name = "deckcraft-model" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-color", "deckcraft-geom", @@ -1059,15 +1059,15 @@ dependencies = [ [[package]] name = "deckcraft-ogg" -version = "0.6.0" +version = "0.6.1" [[package]] name = "deckcraft-opus" -version = "0.6.0" +version = "0.6.1" [[package]] name = "deckcraft-pdf" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-fonts", "deckcraft-model", @@ -1082,7 +1082,7 @@ dependencies = [ [[package]] name = "deckcraft-pptx" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-color", "deckcraft-engine", @@ -1098,7 +1098,7 @@ dependencies = [ [[package]] name = "deckcraft-render" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-color", "deckcraft-fonts", @@ -1114,7 +1114,7 @@ dependencies = [ [[package]] name = "deckcraft-text" -version = "0.6.0" +version = "0.6.1" dependencies = [ "chrono", "deckcraft-color", @@ -1128,7 +1128,7 @@ dependencies = [ [[package]] name = "deckcraft-ui-egui" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1150,7 +1150,7 @@ dependencies = [ [[package]] name = "deckcraft-vp9" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1159,7 +1159,7 @@ dependencies = [ [[package]] name = "deckcraft-web" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-engine", "deckcraft-ui-egui", @@ -5655,7 +5655,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index fa8ddb4c..90715e5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"] [workspace.package] -version = "0.6.0" +version = "0.6.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 235e34613becf6c0c33a4da89b058a0fcf6bf12b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:35:28 -0400 Subject: [PATCH 03/49] Text: superscript and subscript advance at their drawn size (#264) Superscript and subscript glyphs were drawn at two thirds of the run's size but advanced by the full size, so the letters spread apart ("s u p e r") and the line ran wider than in PowerPoint. The cluster advance now uses the same scale as the glyphs. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 14 ++++++++++---- crates/text/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 225f4afb..287c442b 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -566,6 +566,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa cells } +/// Size of superscript and subscript text relative to the run's size. +const SUB_SUPER: f64 = 0.66; + /// Shape cells `range` (one item) and hang each cluster's glyphs on its first cell, sharing the /// cluster's advance among its graphemes (by the ligature's GDEF carets when it has them). #[allow(clippy::too_many_arguments)] @@ -593,7 +596,11 @@ fn shape_item( let st = styles.get(cell.style); let size = st.map(|s| s.size).unwrap_or(18.0); let small = st.is_some_and(|s| s.caps == Caps::Small) && cell.ch.is_lowercase(); - (if small { size * 0.8 } else { size }) / upem + let size = if small { size * 0.8 } else { size }; + // Superscript and subscript glyphs are drawn at two thirds of the size, so they advance by + // two thirds too. + let size = if st.is_some_and(|s| s.baseline != 0.0) { size * SUB_SUPER } else { size }; + size / upem }; // Cluster heads in this item, ascending; each cluster spans to the next head. let mut heads: Vec = glyphs.iter().filter_map(|g| char_at_byte.get(&g.cluster).copied()).filter(|h| range.contains(h)).collect(); @@ -1155,7 +1162,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts let Some(st) = p.styles.get(cell.style) else { continue }; let Some(&(cx, cx1)) = line.edges.get(k) else { continue }; let base_y = line.baseline - st.baseline * st.size; - let size = if st.baseline != 0.0 { st.size * 0.66 } else { st.size }; + let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size }; let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size }; let key = (cell.style, cell.face); if key != cur_key { @@ -1204,9 +1211,8 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } } } - let k66 = if st.baseline != 0.0 { 0.66 } else { 1.0 }; for (gid, gx, gy) in &cell.glyphs { - r.glyphs.push((*gid, cl + kash + gx * k66, base_y + gy)); + r.glyphs.push((*gid, cl + kash + gx, base_y + gy)); r.cells.push((ci, cell.ch)); } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f3d5db5d..0f6c6470 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -395,3 +395,41 @@ fn hostile_bidi_input_never_panics() { } } } + +/// Width of `word` laid out in a run with `baseline` (0 = normal, 0.3 = superscript …). +fn run_width(word: &str, baseline: Option) -> f64 { + let mut run = Run::new(word); + run.props.size = Some(24.0); + run.props.baseline = baseline; + let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left); + let l = lay(&p, &sh, 2000.0, 300.0); + let line = &l.lines[0]; + line.caret_x.last().unwrap() - line.caret_x.first().unwrap() +} + +#[test] +fn superscript_and_subscript_advance_at_their_drawn_size() { + // The glyphs are drawn at two thirds of the size; advancing by the full size spread the + // letters apart ("s u p e r"). + let normal = run_width("superscript", None); + for b in [Some(0.3), Some(-0.25)] { + let w = run_width("superscript", b); + assert!((w / normal - SUB_SUPER).abs() < 0.02, "{b:?}: {w} vs {normal}"); + } +} + +#[test] +fn superscript_glyphs_sit_inside_their_cells() { + let mut run = Run::new("super"); + run.props.size = Some(24.0); + run.props.baseline = Some(0.3); + let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left); + let l = lay(&p, &sh, 2000.0, 300.0); + let line = &l.lines[0]; + let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap(); + assert!((r.size - 24.0 * SUB_SUPER).abs() < 1e-6); + for (g, (ci, _)) in r.glyphs.iter().zip(&r.cells) { + let (x0, x1) = line.edges[*ci - line.start]; + assert!(g.1 >= x0 - 0.01 && g.1 < x1, "glyph at {} outside its cell {x0}..{x1}", g.1); + } +} From 4d13652f0fb86d854297a458ddffa53b8d286e20 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:35:52 -0400 Subject: [PATCH 04/49] Pictures: duotone recolours, and layout/slide colour map overrides apply (#269) Theme background pictures (Office 2007-era themes such as Treck) carry an a:duotone recolour in phClr; DeckCraft read it but drew the picture unchanged, so those backgrounds came out grey. The painter now resolves the duotone's colours against the slide's colours and placeholder colour, and the decoded-image cache applies them (luminance picks a colour between dark and light) and keys on them. Layouts and slides can replace the master's colour map (p:clrMapOvr/a:overrideClrMapping, e.g. dark section layouts mapping bg1 to dk1). They were read as the master's map; now Layout and Slide carry an optional colour map, resolve::Ctx uses slide > layout > master, and the writer keeps them. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/design.rs | 1 + crates/model/src/defaults.rs | 1 + crates/model/src/lib.rs | 50 +++++++++++++++++++++++---------- crates/model/src/resolve.rs | 12 +++++--- crates/model/src/tests.rs | 28 ++++++++++++++++++ crates/pptx/src/read/mod.rs | 7 +++++ crates/pptx/src/write/mod.rs | 25 ++++++++++++----- crates/pptx/tests/roundtrip.rs | 36 ++++++++++++++++++++++++ crates/render/src/images.rs | 43 ++++++++++++++++++++++++++++ crates/render/src/lib.rs | 2 +- crates/render/src/paint.rs | 14 +++++++-- 11 files changed, 190 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 641ddffc..3cad3f00 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -483,6 +483,7 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result { background: None, show_master_shapes: true, preserve: true, + color_map: None, raw_ext: None, }, ); diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 8a2b5b4c..9b50ae27 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -243,6 +243,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { background: None, show_master_shapes: true, preserve: true, + color_map: None, raw_ext: None, }); }; diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 6940fa4b..ef65a797 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -488,6 +488,10 @@ pub struct Slide { /// Speaker notes. pub notes: TextBody, pub comments: Vec, + /// `clrMapOvr`: a colour map replacing the layout's or master's (`bg1`→`dk1`…); None follows + /// the layout. + #[serde(skip_serializing_if = "Option::is_none")] + pub color_map: Option>, /// Unmodelled XML kept for round-trip (custom data, tags). #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, @@ -507,6 +511,7 @@ impl Default for Slide { animations: vec![], notes: TextBody::default(), comments: vec![], + color_map: None, raw_ext: None, } } @@ -596,6 +601,10 @@ pub struct Layout { pub background: Option, pub show_master_shapes: bool, pub preserve: bool, + /// `clrMapOvr`: a colour map replacing the master's for this layout and the slides using it + /// (dark "section" layouts map `bg1` to `dk1`); None follows the master. + #[serde(skip_serializing_if = "Option::is_none")] + pub color_map: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, } @@ -641,24 +650,17 @@ impl Master { } /// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map. pub fn map_slot(&self, slot: SchemeSlot) -> SchemeSlot { - let key = match slot { - SchemeSlot::Bg1 => "bg1", - SchemeSlot::Tx1 => "tx1", - SchemeSlot::Bg2 => "bg2", - SchemeSlot::Tx2 => "tx2", - other => return other, - }; - self.color_map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot { - SchemeSlot::Bg1 => SchemeSlot::Lt1, - SchemeSlot::Tx1 => SchemeSlot::Dk1, - SchemeSlot::Bg2 => SchemeSlot::Lt2, - _ => SchemeSlot::Dk2, - }) + map_slot(&self.color_map, slot) } /// The theme's colour scheme with the colour map applied (`bg1`… resolve to mapped colours). pub fn scheme(&self) -> ColorScheme { + self.scheme_with(&self.color_map) + } + /// The theme's colour scheme with `map` (a layout's or slide's override, or the master's own) + /// applied. + pub fn scheme_with(&self, map: &[(String, String)]) -> ColorScheme { let mut s = self.theme.colors.clone(); - if self.color_map.is_empty() { + if map.is_empty() { return s; } let base = self.theme.colors.clone(); @@ -668,12 +670,30 @@ impl Master { (SchemeSlot::Bg2, SchemeSlot::Lt2), (SchemeSlot::Tx2, SchemeSlot::Dk2), ] { - s.set(slot, base.get(self.map_slot(alias))); + s.set(slot, base.get(map_slot(map, alias))); } s } } +/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map `map` (the default mapping when it has +/// no entry). +pub fn map_slot(map: &[(String, String)], slot: SchemeSlot) -> SchemeSlot { + let key = match slot { + SchemeSlot::Bg1 => "bg1", + SchemeSlot::Tx1 => "tx1", + SchemeSlot::Bg2 => "bg2", + SchemeSlot::Tx2 => "tx2", + other => return other, + }; + map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot { + SchemeSlot::Bg1 => SchemeSlot::Lt1, + SchemeSlot::Tx1 => SchemeSlot::Dk1, + SchemeSlot::Bg2 => SchemeSlot::Lt2, + _ => SchemeSlot::Dk2, + }) +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Section { diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 8769b571..3ea7f998 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -22,23 +22,27 @@ pub struct Ctx<'a> { pub layout: Option<&'a Layout>, pub owner: Owner, pub scheme: ColorScheme, + /// The colour map in force: the slide's override, else its layout's, else the master's. + pub color_map: Vec<(String, String)>, } impl<'a> Ctx<'a> { pub fn for_slide(pres: &'a Presentation, slide: &Slide) -> Option> { let (master, layout) = pres.master_for(slide)?; - Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme() }) + let map = slide.color_map.as_ref().or(layout.and_then(|l| l.color_map.as_ref())).unwrap_or(&master.color_map).clone(); + Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme_with(&map), color_map: map }) } pub fn for_layout(pres: &'a Presentation, master: &'a Master, layout: &'a Layout) -> Ctx<'a> { - Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme() } + let map = layout.color_map.as_ref().unwrap_or(&master.color_map).clone(); + Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme_with(&map), color_map: map } } pub fn for_master(pres: &'a Presentation, master: &'a Master) -> Ctx<'a> { - Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme() } + Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme(), color_map: master.color_map.clone() } } pub fn color(&self, c: &ColorRef, ph: Option) -> Rgba { let c = match &c.base { crate::ColorBase::Scheme { slot } if matches!(slot, SchemeSlot::Bg1 | SchemeSlot::Tx1 | SchemeSlot::Bg2 | SchemeSlot::Tx2) => { - ColorRef { base: crate::ColorBase::Scheme { slot: self.master.map_slot(*slot) }, mods: c.mods.clone() } + ColorRef { base: crate::ColorBase::Scheme { slot: crate::map_slot(&self.color_map, *slot) }, mods: c.mods.clone() } } _ => c.clone(), }; diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index c34fa17f..2d028f86 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -159,3 +159,31 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() { let dbl = resolve::run(&ctx, title, ¶, &RunProps { link, underline: Some("dbl".into()), ..Default::default() }); assert_eq!((dbl.fill, dbl.underline.as_deref()), (hlink, Some("dbl"))); } + +#[test] +fn a_layout_colour_map_override_applies_to_its_slides() { + // A dark "section" layout maps bg1→dk1 and tx1→lt1; a slide on it that keeps the master + // mapping (`masterClrMapping`) follows the layout, as PowerPoint draws it. + let mut p = Presentation::default(); + let dark: Vec<(String, String)> = + [("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect(); + let layout = p.slides[0].layout; + let m = std::sync::Arc::make_mut(&mut p.masters[0]); + let colors = m.theme.colors.clone(); + m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = Some(dark.clone()); + let s: Slide = (*p.slides[0]).clone(); + let ctx = Ctx::for_slide(&p, &s).unwrap(); + assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1)); + assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Tx2), None), colors.get(SchemeSlot::Lt2)); + assert_eq!(ctx.scheme.get(SchemeSlot::Lt1), colors.get(SchemeSlot::Dk1)); + // The slide's own override wins over the layout's. + let mut own = s.clone(); + own.color_map = Some([("bg1", "lt1"), ("tx1", "dk1")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect()); + let ctx = Ctx::for_slide(&p, &own).unwrap(); + assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1)); + // The layout itself draws with its override; the master keeps its own map. + let m = &p.masters[0]; + let l = m.layout(layout).unwrap(); + assert_eq!(Ctx::for_layout(&p, m, l).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1)); + assert_eq!(Ctx::for_master(&p, m).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1)); +} diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index b8c403b5..0a42dd21 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -523,12 +523,18 @@ fn read_layout(imp: &mut Imp, part: &Part, doc: &Doc) -> Layout { l.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0); } } + l.color_map = color_map_override(root); if let Some(ext) = root.child("extLst").filter(|e| !has_rel_refs(e)) { l.raw_ext = Some(ext.elements().map(|e| part.keep(e)).collect()); } l } +/// A layout's or slide's `clrMapOvr/overrideClrMapping` (None for `masterClrMapping` or none). +fn color_map_override(root: &El) -> Option> { + root.child("clrMapOvr")?.child("overrideClrMapping").map(color_map) +} + fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: LayoutId) -> Slide { let root = &doc.root; let mut s = Slide { @@ -536,6 +542,7 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout layout, hidden: root.bool("show") == Some(false), show_master_shapes: root.bool("showMasterSp").unwrap_or(true), + color_map: color_map_override(root), ..Default::default() }; let mut ctx = shapes::IdCtx::default(); diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 5d15246d..0329e2ef 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -227,6 +227,21 @@ const CLR_KEYS: [(&str, &str); 12] = [ ]; fn clr_map(w: &mut W, map: &[(String, String)]) { + w.empty("p:clrMap", clr_map_attrs(map)); +} + +/// `p:clrMapOvr`: the layout's or slide's own colour map, else `a:masterClrMapping`. +fn clr_map_ovr(w: &mut W, map: Option<&[(String, String)]>) { + w.open0("p:clrMapOvr"); + match map { + Some(m) => w.empty("a:overrideClrMapping", clr_map_attrs(m)), + None => w.empty0("a:masterClrMapping"), + } + w.close("p:clrMapOvr"); +} + +/// The twelve colour map attributes, each a theme slot (the default for anything else). +fn clr_map_attrs(map: &[(String, String)]) -> A { let mut a = A::new(); for (k, d) in CLR_KEYS { let v = map @@ -237,7 +252,7 @@ fn clr_map(w: &mut W, map: &[(String, String)]) { .unwrap_or(d); a = a.a(k, v); } - w.empty("p:clrMap", a); + a } fn ext_list(w: &mut W, raw: Option<&str>) { @@ -522,9 +537,7 @@ fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec { A::new().o("showMasterSp", (!l.show_master_shapes).then_some("0")).a("type", l.kind.xml()).t("preserve", l.preserve), ); csld(&mut w, x, o, &l.name, l.background.as_ref(), &l.shapes); - w.open0("p:clrMapOvr"); - w.empty0("a:masterClrMapping"); - w.close("p:clrMapOvr"); + clr_map_ovr(&mut w, l.color_map.as_deref()); ext_list(&mut w, ext_children(l.raw_ext.as_deref()).as_deref()); w.close("p:sldLayout"); w.finish() @@ -556,9 +569,7 @@ fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec { let mut w = W::new(); root_open(&mut w, "p:sld", A::new().o("showMasterSp", (!s.show_master_shapes).then_some("0")).o("show", s.hidden.then_some("0"))); let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes); - w.open0("p:clrMapOvr"); - w.empty0("a:masterClrMapping"); - w.close("p:clrMapOvr"); + clr_map_ovr(&mut w, s.color_map.as_deref()); if let Some(t) = &s.transition { timing::transition(&mut w, x, o, t); } diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 420a3921..fddb0d97 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -730,3 +730,39 @@ fn date_fields_keep_their_type_and_saved_text() { assert_eq!(by_name(&q.slides[0], name).text, by_name(&p.slides[0], name).text, "{name}"); } } + +#[test] +fn colour_map_overrides_round_trip() { + let mut p = defaults::new_presentation(None); + let dark: Vec<(String, String)> = + [("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect(); + let layout = p.slides[0].layout; + std::sync::Arc::make_mut(&mut p.masters[0]).layouts.iter_mut().find(|l| l.id == layout).expect("layout").color_map = Some(dark.clone()); + let first = p.slides[0].id; + p.slide_mut(first).expect("slide").color_map = Some(dark.clone()); + let q = round(&p); + let get = |m: &Option>, k: &str| m.as_ref().and_then(|m| m.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone())); + let ql = q.masters[0].layouts.iter().find(|l| l.color_map.is_some()).expect("the layout keeps its override"); + assert_eq!(get(&ql.color_map, "bg1").as_deref(), Some("dk1")); + assert_eq!(get(&q.slides[0].color_map, "tx1").as_deref(), Some("lt1")); + // Layouts and slides without one keep following the master. + assert!(q.masters[0].layouts.iter().filter(|l| l.color_map.is_none()).count() >= 1); +} + +#[test] +fn a_picture_duotone_round_trips() { + let mut p = defaults::new_presentation(None); + let img = p.add_media("image1.png", "image/png", png(4, 4, [128, 128, 128])); + let mut pic = shape(&mut p, "Duotone", 10.0, 10.0, 100.0, 100.0); + let mut pf = PictureFill { media: img, ..Default::default() }; + pf.adjust.duotone = Some((ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Shade(30000)), ColorRef::rgb(Rgba::rgb(250, 240, 200)))); + pic.kind = deckcraft_model::ShapeKind::Picture { fill: pf }; + let first = p.slides[0].id; + p.slide_mut(first).expect("slide").shapes.push(pic); + let q = round(&p); + let sh = by_name(&q.slides[0], "Duotone"); + let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("picture") }; + let (d, l) = fill.adjust.duotone.as_ref().expect("duotone kept"); + assert!(matches!(d.base, deckcraft_model::ColorBase::Scheme { slot: SchemeSlot::Accent1 })); + assert!(matches!(l.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(250, 240, 200))); +} diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index f95102a8..09e1baa4 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -79,9 +79,26 @@ fn adjust_key(a: &PictureAdjust) -> u64 { if let Some(c) = a.clear_color { mix(((c.r as u64) << 16) | ((c.g as u64) << 8) | c.b as u64 | 1 << 40); } + if let Some((d, l)) = duotone_rgb(a) { + for c in [d, l] { + mix(((c.r as u64) << 24) | ((c.g as u64) << 16) | ((c.b as u64) << 8) | c.a as u64 | 1 << 41); + } + } h } +/// The duotone's dark and light colours. The painter resolves them to plain RGB first (see +/// `paint::resolved`); a colour that is still a scheme reference falls back to black and white. +fn duotone_rgb(a: &PictureAdjust) -> Option<(deckcraft_color::Rgba, deckcraft_color::Rgba)> { + use deckcraft_model::style::ColorBase; + let (d, l) = a.duotone.as_ref()?; + let rgb = |c: &deckcraft_model::style::ColorRef, or: deckcraft_color::Rgba| match &c.base { + ColorBase::Rgb { rgb } if c.mods.is_empty() => *rgb, + _ => or, + }; + Some((rgb(d, deckcraft_color::Rgba::BLACK), rgb(l, deckcraft_color::Rgba::WHITE))) +} + /// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF) to straight RGBA. pub fn decode(bytes: &[u8]) -> Option { if bytes.len() > 512 << 20 { @@ -233,6 +250,7 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) { if a.brightness == 0.0 && a.contrast == 0.0 && sat == 1.0 && !a.grayscale && a.clear_color.is_none() && a.duotone.is_none() { return; } + let duo = duotone_rgb(a); let b = a.brightness.clamp(-1.0, 1.0) * 255.0; let c = a.contrast.clamp(-1.0, 1.0); let cf = if c >= 0.0 { 1.0 / (1.0 - c * 0.99) } else { 1.0 + c }; @@ -252,6 +270,12 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) { *v = y + (*v - y) * s; *v = (*v - 128.0) * cf + 128.0 + b; } + // Duotone: the pixel's luminance picks a colour between the dark and the light one. + if let Some((d, l)) = duo { + let t = ((0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255.0).clamp(0.0, 1.0); + rgb = + [d.r as f64 + (l.r as f64 - d.r as f64) * t, d.g as f64 + (l.g as f64 - d.g as f64) * t, d.b as f64 + (l.b as f64 - d.b as f64) * t]; + } for (i, v) in rgb.iter().enumerate() { if let Some(ch) = p.0.get_mut(i) { *ch = v.round().clamp(0.0, 255.0) as u8; @@ -266,6 +290,25 @@ mod tests { use super::*; + #[test] + fn duotone_maps_luminance_between_its_two_colours() { + use deckcraft_color::Rgba; + use deckcraft_model::style::ColorRef; + let mut img = image::RgbaImage::from_fn(3, 1, |x, _| image::Rgba([[0, 0, 0, 255], [128, 128, 128, 200], [255, 255, 255, 255]][x as usize])); + let adj = + PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(100, 40, 0)), ColorRef::rgb(Rgba::rgb(250, 240, 200)))), ..Default::default() }; + apply_adjust(&mut img, &adj); + assert_eq!(img.get_pixel(0, 0).0, [100, 40, 0, 255], "black takes the dark colour"); + assert_eq!(img.get_pixel(2, 0).0, [250, 240, 200, 255], "white takes the light colour"); + let mid = img.get_pixel(1, 0).0; + assert!((mid[0] as i32 - 175).abs() <= 1 && (mid[1] as i32 - 140).abs() <= 1 && (mid[2] as i32 - 100).abs() <= 1, "{mid:?}"); + assert_eq!(mid[3], 200, "alpha is kept"); + // Different duotones are cached separately. + let other = PictureAdjust { duotone: Some((ColorRef::rgb(Rgba::rgb(0, 0, 0)), ColorRef::rgb(Rgba::rgb(255, 0, 0)))), ..Default::default() }; + assert_ne!(adjust_key(&adj), adjust_key(&other)); + assert_ne!(adjust_key(&adj), adjust_key(&PictureAdjust::default())); + } + #[test] fn a_gif_of_many_tiny_frames_keeps_only_the_frame_cap() { use image::{Delay, Frame, Rgba, RgbaImage}; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6dde577a..b4470d6d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -683,7 +683,7 @@ impl Renderer { let bounds = Rect::new(0.0, 0.0, w, h); ctx.push_clip_layer(&outline); let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1); - paint::picture(ctx, f.pres, pf, bounds, m, t); + paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); ctx.pop_layer(); } ShapeKind::Media(mc) => { diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 1dcbcd86..af8c7684 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -3,7 +3,7 @@ use deckcraft_color::Rgba; use deckcraft_geom::preset::FillMode; use deckcraft_model::resolve::Ctx; -use deckcraft_model::style::{Fill, GradientShape, PictureFill, PictureMode}; +use deckcraft_model::style::{ColorRef, Fill, GradientShape, PictureFill, PictureMode}; use deckcraft_model::{PhType, Presentation}; use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; use vello_cpu::{RenderContext, peniko}; @@ -65,7 +65,7 @@ pub fn fill_path( } Fill::Picture(pf) => { ctx.push_clip_layer(path); - picture(ctx, pres, pf, bounds, m, None); + picture(ctx, pres, &resolved(rctx, pf, ph), bounds, m, None); ctx.pop_layer(); } Fill::Pattern(p) => { @@ -120,6 +120,16 @@ fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath { } fn p_add(_a: &mut BezPath, _b: &mut BezPath) {} +/// `pf` with its duotone colours resolved to plain RGB against the slide's colours and the +/// placeholder colour `ph` (theme background pictures recolour with `phClr`), so the decoded-image +/// cache can apply and key them without a colour scheme. +pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option) -> std::borrow::Cow<'a, PictureFill> { + let Some((d, l)) = &pf.adjust.duotone else { return std::borrow::Cow::Borrowed(pf) }; + let mut out = pf.clone(); + out.adjust.duotone = Some((ColorRef::rgb(rctx.color(d, ph)), ColorRef::rgb(rctx.color(l, ph)))); + std::borrow::Cow::Owned(out) +} + /// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated /// GIF shows its frame at `gif_time` seconds. pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option) { From ceb6e3b990f9fe73be3e932754d633fdf73bf96c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:35:59 -0400 Subject: [PATCH 05/49] Render: two-colour gradients ramp like PowerPoint's (#273) PowerPoint blends a gradient of exactly two stops at 0% and 100% in linear light, with the second colour's share following an S-shaped curve (black to white is 105/186/238 at a quarter/half/three quarters, not 64/128/191). Gradients with more stops, or a stop moved off an end, stay linear in sRGB. Measured with PowerPoint's slide export on shapes and backgrounds, in both directions. The two-colour case is expanded into sixteen pieces per segment so vello's sRGB interpolation draws the same ramp. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 1 + crates/render/src/paint.rs | 7 +- crates/render/src/ramp.rs | 128 +++++++++++++++++++++++++++++++++++++ crates/render/src/tests.rs | 49 ++++++++++++++ 4 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 crates/render/src/ramp.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b4470d6d..3f6bea1f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -13,6 +13,7 @@ mod morph_path; mod morph_text; mod paint; mod placed; +mod ramp; mod table; use std::collections::HashMap; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index af8c7684..365178aa 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -35,10 +35,11 @@ pub fn fill_path( if g.stops.is_empty() { return; } - let mut stops: Vec<(f32, peniko::Color)> = - g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0) as f32, color(shade(rctx.color(&s.color, ph), mode), alpha))).collect(); + let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect(); stops.sort_by(|a, b| a.0.total_cmp(&b.0)); - let stops: Vec = stops.into_iter().map(|(o, c)| peniko::ColorStop { offset: o, color: c.into() }).collect(); + // PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`. + let stops: Vec = + crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect(); let grad = match &g.shape { GradientShape::Linear { angle, .. } => { // Angle measured clockwise from left→right; span the bounds along that direction. diff --git a/crates/render/src/ramp.rs b/crates/render/src/ramp.rs new file mode 100644 index 00000000..97c6c9c2 --- /dev/null +++ b/crates/render/src/ramp.rs @@ -0,0 +1,128 @@ +//! Gradient colour ramps as PowerPoint draws them. +//! +//! A gradient of exactly two stops, at 0 % and 100 % (what a two-colour gradient is), is not +//! blended linearly in sRGB by PowerPoint: it mixes the colours in linear light, and the second +//! colour's share follows an S-shaped curve of the position. Black to white is 105 (not 64) a +//! quarter of the way, 186 (not 128) half way and 238 (not 191) three quarters of the way, in any +//! direction, on shapes and on slide backgrounds. Gradients with three or more stops, or with a +//! stop moved off an end, blend linearly in sRGB (measured from PowerPoint's own slide export). +//! The renderer interpolates linearly in sRGB, so a two-colour gradient is expanded into short +//! pieces whose colours follow PowerPoint's ramp. + +use deckcraft_color::Rgba; + +/// The next stop's share of the linear-light mix at sixteenths of a segment (0 at the first stop, +/// 1 at the second). Measured, then made symmetric: `w(1 - t) = 1 - w(t)`. +const WEIGHT: [f64; 17] = + [0.0, 0.0205, 0.0469, 0.0878, 0.1431, 0.2143, 0.2989, 0.3972, 0.5, 0.6028, 0.7011, 0.7857, 0.8569, 0.9122, 0.9531, 0.9795, 1.0]; + +/// Pieces each segment between two stops is cut into. +const PIECES: usize = 16; + +/// The next stop's share at `t` (0..1) between two stops. +pub fn weight(t: f64) -> f64 { + if !t.is_finite() { + return 0.0; + } + let x = t.clamp(0.0, 1.0) * (WEIGHT.len() - 1) as f64; + let i = (x.floor() as usize).min(WEIGHT.len() - 2); + let f = x - i as f64; + let (a, b) = (WEIGHT.get(i).copied().unwrap_or(0.0), WEIGHT.get(i + 1).copied().unwrap_or(1.0)); + a + (b - a) * f +} + +fn to_linear(v: u8) -> f64 { + let v = v as f64 / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } +} + +fn to_srgb(v: f64) -> u8 { + let v = v.clamp(0.0, 1.0); + let s = if v <= 0.0031308 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; + (s * 255.0).round().clamp(0.0, 255.0) as u8 +} + +/// The colour at `t` between stops `a` and `b`. Alpha mixes by the same share. +pub fn mix(a: Rgba, b: Rgba, t: f64) -> Rgba { + let w = weight(t); + let ch = |x: u8, y: u8| to_srgb(to_linear(x) + (to_linear(y) - to_linear(x)) * w); + Rgba { r: ch(a.r, b.r), g: ch(a.g, b.g), b: ch(a.b, b.b), a: (a.a as f64 + (b.a as f64 - a.a as f64) * w).round().clamp(0.0, 255.0) as u8 } +} + +/// Sorted stops (position 0..1, colour) as a renderer interpolating linearly in sRGB should get +/// them: a two-colour gradient (two stops, at 0 and 1) expanded to follow PowerPoint's ramp, +/// anything else unchanged. +pub fn expand(stops: &[(f64, Rgba)]) -> Vec<(f64, Rgba)> { + let two_colour = matches!(stops, [(p, _), (q, _)] if *p <= 1e-6 && *q >= 1.0 - 1e-6); + if !two_colour { + return stops.to_vec(); + } + let mut out = Vec::with_capacity(stops.len() * PIECES + 1); + for (i, &(p, c)) in stops.iter().enumerate() { + out.push((p, c)); + let Some(&(q, d)) = stops.get(i + 1) else { continue }; + if q - p <= 1e-6 || c == d { + continue; + } + for k in 1..PIECES { + let t = k as f64 / PIECES as f64; + out.push((p + (q - p) * t, mix(c, d, t))); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn black_to_white_follows_powerpoint() { + // PowerPoint's export: 105 at a quarter, 186 half way, 238 at three quarters. + let (k, w) = (Rgba::rgb(0, 0, 0), Rgba::rgb(255, 255, 255)); + for (t, want) in [(0.25, 105), (0.5, 186), (0.75, 238)] { + let got = mix(k, w, t).r as i32; + assert!((got - want).abs() <= 3, "{t}: {got} vs {want}"); + } + } + + #[test] + fn colour_pairs_follow_powerpoint() { + // Blue (4472C4) to orange (ED7D31) half way: PowerPoint draws (178, 120, 146). + let m = mix(Rgba::rgb(68, 114, 196), Rgba::rgb(237, 125, 49), 0.5); + assert!((m.r as i32 - 178).abs() <= 3 && (m.g as i32 - 120).abs() <= 3 && (m.b as i32 - 146).abs() <= 3, "{m:?}"); + // Red to blue a quarter of the way: (238, 0, 105). + let m = mix(Rgba::rgb(255, 0, 0), Rgba::rgb(0, 0, 255), 0.25); + assert!((m.r as i32 - 238).abs() <= 3 && (m.b as i32 - 105).abs() <= 3, "{m:?}"); + } + + #[test] + fn the_ends_are_the_stops_and_the_weight_is_symmetric() { + let (a, b) = (Rgba::rgb(10, 200, 30), Rgba::rgb(240, 20, 90)); + assert_eq!(mix(a, b, 0.0), a); + assert_eq!(mix(a, b, 1.0), b); + for k in 0..=32 { + let t = k as f64 / 32.0; + assert!((weight(t) + weight(1.0 - t) - 1.0).abs() < 1e-9); + assert!(weight(t) <= weight((t + 0.01).min(1.0)), "monotonic"); + } + assert_eq!(weight(f64::NAN), 0.0); + } + + #[test] + fn expand_keeps_stops_and_hard_edges() { + let (r, g) = (Rgba::rgb(255, 0, 0), Rgba::rgb(0, 255, 0)); + // Three or more stops, or a stop off an end, blend linearly in sRGB: unchanged. + let e = expand(&[(0.0, r), (0.5, r), (0.5, g), (1.0, g)]); + assert_eq!(e, vec![(0.0, r), (0.5, r), (0.5, g), (1.0, g)]); + assert_eq!(expand(&[(0.2, r), (1.0, g)]), vec![(0.2, r), (1.0, g)]); + assert_eq!(expand(&[(0.0, r), (0.5, g), (1.0, r)]).len(), 3); + // Two equal colours add nothing. + assert_eq!(expand(&[(0.0, r), (1.0, r)]), vec![(0.0, r), (1.0, r)]); + let e = expand(&[(0.0, r), (1.0, g)]); + assert_eq!(e.len(), PIECES + 1); + assert_eq!(e.first(), Some(&(0.0, r))); + assert_eq!(e.last(), Some(&(1.0, g))); + assert!(e.windows(2).all(|w| w[0].0 <= w[1].0)); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ccb4472f..9b7fa019 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -523,3 +523,52 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() { let own = px(&run_deck(RunProps { link, fill: red.clone(), ..Default::default() })); assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red, underline: Some("sng".into()), ..Default::default() }))), 0); } + +#[test] +fn a_linear_gradient_ramps_like_powerpoint() { + // Black to white, left to right: PowerPoint draws 105 a quarter of the way and 186 half way + // (linear-light mix, S-shaped), not 64 and 128. + use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop}; + let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) }; + let sh = Shape { + id: ShapeId(700), + xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)), + fill: Some(Fill::Gradient(Gradient { + stops: vec![stop(0.0, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))], + shape: GradientShape::Linear { angle: 0.0, scaled: false }, + rotate_with_shape: true, + })), + line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), + ..Default::default() + }; + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + for (x, want) in [(200, 105), (400, 186), (600, 238)] { + let got = img.pixel(x, 50)[0] as i32; + assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}"); + } +} + +#[test] +fn a_gradient_with_a_stop_off_the_end_stays_linear() { + // Stops at 20 % and 100 %: PowerPoint blends linearly in sRGB (96 at 50 %, 175 at 75 %). + use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop}; + let stop = |pos, c| GradientStop { pos, color: ColorRef::rgb(c) }; + let sh = Shape { + id: ShapeId(701), + xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)), + fill: Some(Fill::Gradient(Gradient { + stops: vec![stop(0.2, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))], + shape: GradientShape::Linear { angle: 0.0, scaled: false }, + rotate_with_shape: true, + })), + line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), + ..Default::default() + }; + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + for (x, want) in [(400, 96), (600, 175)] { + let got = img.pixel(x, 50)[0] as i32; + assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}"); + } +} From 692aa0103bf18495e3c625fd026ae8a8a2e59e43 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:36:28 -0400 Subject: [PATCH 06/49] PPTX: a picture fill with no stretch or tile tiles (#290) A shape or background blipFill with neither a:stretch nor a:tile (as some tools write it, tdf#128596) was stretched across the shape; PowerPoint tiles the picture at its own size. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/pptx/src/read/dml.rs | 6 +++ crates/pptx/tests/fills.rs | 81 +++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 crates/pptx/tests/fills.rs diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index b4f08e0d..01e08d7f 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -77,6 +77,12 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option { "solidFill" => Fill::Solid { color: color(f).unwrap_or(ColorRef::rgb(Rgba::BLACK)) }, "gradFill" => Fill::Gradient(gradient(f)), "blipFill" => match blip_fill(imp, part, f) { + // A picture filling a shape or background with neither `a:stretch` nor `a:tile` is + // tiled at its own size, as PowerPoint draws it. + Some(mut p) if f.child("stretch").is_none() && f.child("tile").is_none() => { + p.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; + Fill::Picture(p) + } Some(p) => Fill::Picture(p), None => Fill::None, }, diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs new file mode 100644 index 00000000..cd096dea --- /dev/null +++ b/crates/pptx/tests/fills.rs @@ -0,0 +1,81 @@ +//! Fills read from decks written by other tools. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_geom::Xfrm; +use deckcraft_model::style::{Fill, PictureFill, PictureMode}; +use deckcraft_model::{Shape, ShapeId, defaults}; + +/// `bytes` with `edit` applied to every slide part. +fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + let name = f.name().to_string(); + if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") { + d = edit(String::from_utf8(d).expect("utf8")).into_bytes(); + } + out.start_file(name, o).expect("start"); + out.write_all(&d).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +fn png() -> Vec { + let mut out = Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30]))) + .write_to(&mut out, image::ImageFormat::Png) + .expect("png"); + out.into_inner() +} + +/// A deck with one shape filled with a stretched picture, exported. +fn deck() -> Vec { + let mut p = defaults::new_presentation(None); + let media = p.add_media("tile.png", "image/png", png()); + let id = ShapeId(p.alloc_id()); + let first = p.slides[0].id; + p.slide_mut(first).expect("slide").shapes.push(Shape { + id, + name: "Textured".into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)), + fill: Some(Fill::Picture(PictureFill { media, ..Default::default() })), + ..Default::default() + }); + deckcraft_pptx::export(&p).expect("export") +} + +fn textured_mode(bytes: &[u8]) -> PictureMode { + let q = deckcraft_pptx::import(bytes).expect("import"); + let s = q.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape"); + match &s.fill { + Some(Fill::Picture(pf)) => pf.mode.clone(), + other => panic!("{other:?}"), + } +} + +#[test] +fn a_shape_picture_fill_without_stretch_or_tile_tiles() { + // tdf#128596: `` on a shape, as some tools write it; + // PowerPoint tiles the picture at its own size. + let bare = edit_slides(&deck(), |s| { + let s = regex_free_strip(&s, "", ""); + assert!(!s.contains(" String { + let Some(b) = s.find(" Date: Mon, 12 Oct 2026 17:36:37 -0400 Subject: [PATCH 07/49] PPTX: a placeholder without geometry takes its layout's (#301) PowerPoint leaves an inheriting placeholder's spPr without a geometry and draws it with its layout (or master) placeholder's: a picture placeholder cut into a shape, or with holes, keeps that shape on every slide. DeckCraft read such placeholders as rectangles. They're now marked while reading and given their parent's geometry once the layouts are read; anything without a parent stays a rectangle. DeckCraft itself already writes inheriting placeholders this way. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/pptx/src/read/mod.rs | 88 ++++++++++++++++++++++++++++++- crates/pptx/src/read/shapes.rs | 10 ++++ crates/pptx/tests/placeholders.rs | 64 ++++++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 crates/pptx/tests/placeholders.rs diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 0a42dd21..03d162f0 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use deckcraft_geom::{Size, emu_to_pt}; use deckcraft_model::text::TextBody; use deckcraft_model::{ - Background, Comment, CustomShow, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation, + Background, Comment, CustomShow, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation, Properties, Section, ShowSettings, Slide, SlideId, defaults, }; @@ -387,6 +387,7 @@ pub fn import(bytes: &[u8]) -> Result { p.props.company = d.root.child("Company").map(|c| c.text()).unwrap_or_default(); } + inherit_placeholder_geometry(&mut p); p.header_footer = header_footer(&p); p.media = std::mem::take(&mut imp.media); p.next_id = imp.next; @@ -419,6 +420,91 @@ pub fn color_map(cm: &El) -> Vec<(String, String)> { cm.attrs.iter().filter(|(k, _)| !k.starts_with("xmlns")).map(|(k, v)| (k.clone(), v.clone())).collect() } +/// Placeholders read without a geometry (an empty custom geometry, see `shapes::sp`) take the +/// geometry of the layout or master placeholder they inherit from, as PowerPoint draws them: a +/// layout's picture placeholder cut into a shape keeps that shape on the slide. Anything left +/// is a rectangle. +fn inherit_placeholder_geometry(p: &mut Presentation) { + let unset = |g: &Geom| matches!(g, Geom::Custom { paths } if paths.is_empty()); + // Masters first, then layouts (from their masters), then slides (from layouts and masters). + for m in &mut p.masters { + let m = Arc::make_mut(m); + walk_shapes_mut(&mut m.shapes, &mut |s| { + if unset(&s.geom) { + s.geom = Geom::default(); + } + }); + } + let mut layout_geoms: Vec<(LayoutId, usize, Geom)> = vec![]; + for m in &p.masters { + for l in &m.layouts { + let ctx = deckcraft_model::resolve::Ctx::for_layout(p, m, l); + for (i, s) in l.shapes.iter().enumerate() { + if unset(&s.geom) { + let (_, mp) = deckcraft_model::resolve::parents(&ctx, s); + layout_geoms.push((l.id, i, mp.map(|m| m.geom.clone()).filter(|g| !unset(g)).unwrap_or_default())); + } + } + } + } + for m in &mut p.masters { + let m = Arc::make_mut(m); + for l in &mut m.layouts { + for (id, i, g) in layout_geoms.iter().filter(|(id, _, _)| *id == l.id) { + let _ = id; + if let Some(s) = l.shapes.get_mut(*i) { + s.geom = g.clone(); + } + } + } + } + let mut slide_geoms: Vec<(usize, usize, Geom)> = vec![]; + for (si, sl) in p.slides.iter().enumerate() { + let Some(ctx) = deckcraft_model::resolve::Ctx::for_slide(p, sl) else { continue }; + for (i, s) in sl.shapes.iter().enumerate() { + if unset(&s.geom) { + let (lp, mp) = deckcraft_model::resolve::parents(&ctx, s); + let g = lp.map(|l| l.geom.clone()).filter(|g| !unset(g)).or_else(|| mp.map(|m| m.geom.clone()).filter(|g| !unset(g))); + slide_geoms.push((si, i, g.unwrap_or_default())); + } + } + } + for (si, i, g) in slide_geoms { + if let Some(sl) = p.slides.get_mut(si) + && let Some(s) = Arc::make_mut(sl).shapes.get_mut(i) + { + s.geom = g; + } + } + // Placeholders inside groups (rare) and anything still unset: rectangles. + for sl in &mut p.slides { + walk_shapes_mut(&mut Arc::make_mut(sl).shapes, &mut |s| { + if unset(&s.geom) { + s.geom = Geom::default(); + } + }); + } + for m in &mut p.masters { + for l in &mut Arc::make_mut(m).layouts { + walk_shapes_mut(&mut l.shapes, &mut |s| { + if unset(&s.geom) { + s.geom = Geom::default(); + } + }); + } + } +} + +/// Every shape, groups included. +fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut deckcraft_model::Shape)) { + for s in shapes { + f(s); + if let deckcraft_model::ShapeKind::Group { children, .. } = &mut s.kind { + walk_shapes_mut(children, f); + } + } +} + fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option { let bg = csld.child("bg")?; if let Some(pr) = bg.child("bgPr") { diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 2e10e362..c35d266c 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -180,6 +180,15 @@ fn preset_geom(pg: &El) -> Geom { Geom::Preset { name, adj } } +/// A placeholder whose `spPr` names no geometry takes its layout's (or master's) placeholder's: +/// marked here with an empty custom geometry, filled in once the layouts are read +/// (`inherit_placeholder_geometry`). +fn mark_inherited_geometry(s: &mut Shape, pr: Option<&El>) { + if s.ph.is_some() && pr.is_none_or(|pr| pr.child("prstGeom").is_none() && pr.child("custGeom").is_none()) { + s.geom = Geom::Custom { paths: vec![] }; + } +} + /// `spPr` → transform, geometry, fill, line, effects. fn sp_pr(imp: &mut Imp, part: &Part, s: &mut Shape, pr: &El) { if let Some(x) = pr.child("xfrm") { @@ -229,6 +238,7 @@ fn sp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape { if let Some(pr) = e.child("spPr") { sp_pr(imp, part, &mut s, pr); } + mark_inherited_geometry(&mut s, e.child("spPr")); if e.bool("useBgFill") == Some(true) { s.fill = Some(Fill::Background); } diff --git a/crates/pptx/tests/placeholders.rs b/crates/pptx/tests/placeholders.rs new file mode 100644 index 00000000..b2b46100 --- /dev/null +++ b/crates/pptx/tests/placeholders.rs @@ -0,0 +1,64 @@ +//! Placeholder inheritance in decks written by PowerPoint. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_model::{Geom, defaults}; + +/// `bytes` with `edit` applied to every slide part. +fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + let name = f.name().to_string(); + if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") { + d = edit(String::from_utf8(d).expect("utf8")).into_bytes(); + } + out.start_file(name, o).expect("start"); + out.write_all(&d).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +#[test] +fn a_placeholder_without_geometry_takes_its_layouts() { + // PowerPoint leaves a slide placeholder's spPr empty and draws it with the layout + // placeholder's geometry (a picture placeholder cut into a shape keeps the shape). + let mut p = defaults::new_presentation(None); + let layout = p.slides[0].layout; + let m = std::sync::Arc::make_mut(&mut p.masters[0]); + let l = m.layouts.iter_mut().find(|l| l.id == layout).expect("layout"); + for s in &mut l.shapes { + if s.ph.is_some() { + s.geom = Geom::Preset { name: "ellipse".into(), adj: vec![] }; + } + } + let bytes = deckcraft_pptx::export(&p).expect("export"); + // Strip the slide's own geometry, as PowerPoint writes inheriting placeholders. + let bare = edit_slides(&bytes, |s| { + let mut s = s; + while let Some(i) = s.find("").map(|j| j + i + "".len()) else { break }; + s.replace_range(i..j, ""); + } + s + }); + let q = deckcraft_pptx::import(&bare).expect("import"); + let phs: Vec<_> = q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).collect(); + assert!(!phs.is_empty()); + for s in phs { + assert!(matches!(&s.geom, Geom::Preset { name, .. } if name == "ellipse"), "{:?}", s.geom); + } + // A placeholder with its own geometry keeps it. + let first = p.slides[0].id; + for s in &mut p.slide_mut(first).expect("slide").shapes { + if s.ph.is_some() { + s.geom = Geom::Preset { name: "triangle".into(), adj: vec![] }; + } + } + let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import"); + assert!(q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).all(|s| matches!(&s.geom, Geom::Preset { name, .. } if name == "triangle"))); +} From 72d8e0ab42bb1c2892b58e89da5e3b649148d2db Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:36:44 -0400 Subject: [PATCH 08/49] Draw only the first character of a multi-character bullet (#307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PowerPoint draws buChar char="••" (seen in a LibreOffice test deck) as a single dot; DeckCraft drew both, pushing the text right. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 3 ++- crates/text/src/tests.rs | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 287c442b..4cf1b3de 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -686,7 +686,8 @@ fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { }; return m.to_string(); } - c.to_string() + // PowerPoint draws only the first character of a longer `buChar` ("••" draws one dot). + c.graphemes(true).next().unwrap_or("•").to_string() } fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 0f6c6470..959d6145 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -45,6 +45,20 @@ fn bullets_are_drawn_for_body_text() { assert_eq!(l.para_lines.len(), 2); } +#[test] +fn only_the_first_character_of_a_bullet_is_drawn() { + // "••" (seen in the wild) draws one dot in PowerPoint. + for (char, glyphs) in [("\u{2022}\u{2022}", 1), ("\u{2022}", 1), ("-->", 1)] { + let (p, mut sh) = setup("One", 600.0, 300.0); + sh.text.as_mut().unwrap().paragraphs[0].props.bullet = Some(Bullet::Char { char: char.into() }); + let l = lay(&p, &sh, 600.0, 300.0); + let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run"); + assert_eq!(bullet.glyphs.len(), glyphs, "{char:?}"); + } + // The first grapheme cluster is kept whole. + assert_eq!(map_symbol_bullet("e\u{301}x", None), "e\u{301}"); +} + #[test] fn shrink_on_overflow() { let text = (0..30).map(|i| format!("Line number {i}")).collect::>().join("\n"); From e6050b7d378de81e45e7a442322a547a507d7f1f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:41:01 -0400 Subject: [PATCH 09/49] Text: a hang past the box edge keeps its width on the first line (#265) When marL + indent is negative, PowerPoint pins the bullet to the box's edge and starts the first line's text at -indent; DeckCraft put the text right after the number, so numbered lists written with indent=-457200 and no marL lost their gap. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 6 +++++- crates/text/src/tests.rs | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 4cf1b3de..31dc291e 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -917,7 +917,11 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts let bullet_w = p.bullet.as_ref().map(|(s, st)| measure_str(&st.face, st.size, s).0).unwrap_or(0.0); let first_x = if p.bullet.is_some() { let bx = (margin + indent).max(0.0); - if indent < 0.0 && bx + bullet_w <= margin { margin } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } } + // A hanging bullet's text starts at the margin. When the hang reaches left of the box + // (marL + indent < 0), PowerPoint pins the bullet to the box's edge and keeps the + // hang: the first line's text starts at -indent (later lines stay at the margin). + let stop = margin.max(-indent); + if indent < 0.0 && bx + bullet_w <= stop { stop } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } } } else { (margin + indent).max(0.0) }; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 959d6145..25f86f9f 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -447,3 +447,45 @@ fn superscript_glyphs_sit_inside_their_cells() { assert!(g.1 >= x0 - 0.01 && g.1 < x1, "glyph at {} outside its cell {x0}..{x1}", g.1); } } + +/// A wrapping numbered paragraph with `marL` and `indent`; returns (bullet x, first line's text +/// x, second line's text x). +fn hanging(margin: f64, indent: f64, bullet: Bullet) -> (Option, f64, f64) { + let text = "Wrapping words go on and on so the paragraph needs at least two or three lines here"; + let (p, mut sh) = setup(text, 300.0, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + para.props.bullet = Some(bullet); + para.props.margin_left = Some(margin); + para.props.indent = Some(indent); + let l = lay(&p, &sh, 300.0, 300.0); + assert!(l.lines.len() >= 2, "{} lines", l.lines.len()); + let bx = l.runs.iter().find(|r| r.cells.is_empty() && !r.glyphs.is_empty()).map(|r| r.glyphs[0].1); + (bx, l.lines[0].caret_x[0], l.lines[1].caret_x[0]) +} + +#[test] +fn a_hang_past_the_box_edge_keeps_its_width_on_the_first_line() { + // PowerPoint, marL 0 / indent -36pt: the number sits at the box's edge, the first line's text + // 36pt in, and the wrapped lines at the margin (observed with PowerPoint's slide export). + let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 }; + let (bx, first, rest) = hanging(0.0, -36.0, num()); + assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}"); + assert!((bx.unwrap() - rest).abs() < 0.5, "bullet {bx:?} rest {rest}"); + // marL 18 / indent -54: number at the edge, first line at 54, the rest at 18. + let (bx, first, rest) = hanging(18.0, -54.0, num()); + assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}"); + assert!((rest - bx.unwrap() - 18.0).abs() < 0.5, "bullet {bx:?} rest {rest}"); + // A character bullet behaves the same. + let (_, first, rest) = hanging(0.0, -36.0, Bullet::Char { char: "•".into() }); + assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}"); + // Without a bullet the first line just starts at the edge. + let (_, first, rest) = hanging(0.0, -36.0, Bullet::None); + assert!((first - rest).abs() < 0.5, "first {first} rest {rest}"); +} + +#[test] +fn an_ordinary_hang_still_puts_the_text_at_the_margin() { + let (bx, first, rest) = hanging(36.0, -36.0, Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 }); + assert!((first - rest).abs() < 0.5, "first {first} rest {rest}"); + assert!((rest - bx.unwrap() - 36.0).abs() < 0.5); +} From 2e68504e7c7e470992c90489d17a327de168d7fb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:42:53 -0400 Subject: [PATCH 10/49] Text: single-spaced lines are 1.2 times the font size apart, as in PowerPoint (#266) * Text: single-spaced lines are 1.2 times the font size apart, as in PowerPoint PowerPoint spaces single-spaced lines 1.2 x the font size apart in every font (measured on twenty Latin, CJK, Thai and Indic fonts at 18 and 40 pt with PowerPoint's slide export) and puts the baseline where the font's Windows ascent/descent split it. DeckCraft used each font's own ascent plus descent, so Calibri lines were 15% too tight and Segoe UI ones too loose. FontFace gains line_split (usWinAscent / (usWinAscent + usWinDescent)); line_metrics uses it with LINE_PITCH = 1.2. Co-Authored-By: Claude Opus 5.5 (1M context) * Text: tidy the line pitch test Co-Authored-By: Claude Opus 5.5 (1M context) * Fonts: line_split guard reads plainly (clippy) Co-Authored-By: Claude Opus 5.5 (1M context) * Text: note that the 1.2 line pitch was measured on one Windows install Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/fonts/src/fontdb.rs | 22 ++++++++++++++++++++++ crates/fonts/src/lib.rs | 11 +++++++++++ crates/text/src/lib.rs | 15 +++++++++++++-- crates/text/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index f0a06efe..8b2a1ec8 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -79,6 +79,10 @@ pub struct FontFace { /// values). pub cap_height: f64, pub x_height: f64, + /// Where the baseline sits in a line, as a share of the line's height from its top: the + /// Windows ascent over the Windows ascent plus descent (OS/2 `usWinAscent`/`usWinDescent`), + /// the split PowerPoint uses (computed by `line_split`). + pub line_split: f64, pub shaper: harfrust::ShaperData, /// Variable fonts: the named instance's axis settings (user units; empty = default instance), /// the normalised location and the shaper's view of it. @@ -436,6 +440,23 @@ pub(crate) fn last_resort_face() -> Arc { .clone() } +/// The share of a line above the baseline (`FontFace::line_split`): OS/2 `usWinAscent` over +/// `usWinAscent + usWinDescent`, else the ascent over ascent plus descent. Fonts whose Windows +/// extent is huge (Gabriola, Cambria Math: over 1.5 em) get 0.81, where PowerPoint puts their +/// baseline (observed). +fn line_split(f: &skrifa::FontRef, ascent: f64, descent: f64, upem: f64) -> f64 { + use skrifa::raw::TableProvider; + let (a, d) = match f.os2() { + Ok(os2) if os2.us_win_ascent() > 0 => (os2.us_win_ascent() as f64, os2.us_win_descent() as f64), + _ => (ascent, descent), + }; + let total = a + d; + if !total.is_finite() || total <= 0.0 || a < 0.0 || d < 0.0 { + return 0.8; + } + if total > upem * 1.5 { 0.81 } else { (a / total).clamp(0.5, 1.0) } +} + fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option { let data: &[u8] = match &bytes { FontBytes::Static(b) => b, @@ -467,6 +488,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords descent: -(m.descent as f64), cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72), x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5), + line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64), shaper, coords, location, diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 94e8081f..74ce9211 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -143,6 +143,17 @@ mod tests { assert!(f.upem > 0.0); } + #[test] + fn line_split_follows_the_windows_ascent_and_descent() { + use skrifa::raw::TableProvider; + let f = fontdb::last_resort_face(); + let font = skrifa::FontRef::new(f.data()).unwrap(); + let os2 = font.os2().unwrap(); + let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64); + assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d)); + assert!(f.line_split > 0.5 && f.line_split < 1.0); + } + #[test] fn some_face_always_resolves() { let db = FontDb::with_font_dirs(Vec::new()); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 31dc291e..5eeb40c5 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -702,9 +702,20 @@ fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) { (x, out) } +/// Single line spacing as a multiple of the font size. PowerPoint spaces single-spaced lines 1.2 +/// times the size apart in every font (observed on twenty Latin, CJK, Thai and Indic fonts at 18 +/// and 40 pt), whatever the font's own ascent and descent. +/// +/// Caveat: this was measured on one Windows install of desktop PowerPoint. It may be a Windows or +/// machine setting; PowerPoint on macOS or the web may space lines by the font's own metrics. +/// Keep it a single tunable value, and re-measure on the other platform before changing it. +const LINE_PITCH: f64 = 1.2; + +/// A line's height above and below the baseline at `size`: [`LINE_PITCH`] × size, split where +/// the font's Windows ascent and descent put the baseline. fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) { - let k = size / face.upem.max(1.0); - (face.ascent * k, face.descent * k) + let h = size * LINE_PITCH; + (h * face.line_split, h * (1.0 - face.line_split)) } /// Break opportunities: allowed[i] = a line may start at char i. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 25f86f9f..09595764 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -489,3 +489,34 @@ fn an_ordinary_hang_still_puts_the_text_at_the_margin() { assert!((first - rest).abs() < 0.5, "first {first} rest {rest}"); assert!((rest - bx.unwrap() - 36.0).abs() < 0.5); } + +#[test] +fn single_spaced_lines_are_1_2_times_the_size_apart_whatever_the_font() { + // PowerPoint spaces single-spaced lines 1.2 × the font size apart in every font, with the + // baseline where the font's Windows ascent/descent split puts it (observed on twenty fonts). + for size in [18.0, 40.0] { + let mut run = Run::new("HHH"); + run.props.size = Some(size); + let (p, mut sh) = bidi_setup(vec![run], 2000.0, false, Align::Left); + let t = sh.text.as_mut().unwrap(); + t.body.autofit = Some(AutoFit::None); + let second = t.paragraphs[0].clone(); + t.paragraphs.push(second.clone()); + t.paragraphs.push(second); + for para in &mut t.paragraphs { + para.props.line_spacing = Some(Spacing::Pct(1.0)); + para.props.space_before = Some(Spacing::Pts(0.0)); + para.props.space_after = Some(Spacing::Pts(0.0)); + } + let l = lay(&p, &sh, 2000.0, 600.0); + assert_eq!(l.lines.len(), 3); + // Each line is 1.2 × the size tall (the face is not compared across lines: another + // test's thread can load the family between them, see #252). + for line in &l.lines { + assert!((line.bottom - line.top - size * LINE_PITCH).abs() < 1e-6, "{size}: {}", line.bottom - line.top); + } + let face = &l.runs[0].face; + let first = &l.lines[0]; + assert!((first.baseline - first.top - size * LINE_PITCH * face.line_split).abs() < 1e-6); + } +} From 98ea8d319561d2b98ae8fbc539763d124da4c0e7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:44:20 -0400 Subject: [PATCH 11/49] Render: shapes filled with the background (useBgFill) show it (#274) A shape with useBgFill="1" is a window onto the slide's background, laid out on the slide. DeckCraft drew nothing in it, so a master that covers the background with a white panel and cuts a background-filled frame into it (tdf93868) rendered all white. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 17 +++++++++++++++-- crates/render/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3f6bea1f..e26f8c61 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -222,11 +222,14 @@ pub struct Renderer { resources: Resources, /// Outlines morphing in the frame being drawn by `blend`. paths: Vec, + /// The background of the slide (layout, master) being drawn, its placeholder colour and the + /// slide's view transform: shapes filled with the background (`useBgFill`) show it through. + bg: Option<(Fill, Option, Affine)>, } impl Default for Renderer { fn default() -> Self { - Renderer { resources: Resources::new(), paths: Vec::new() } + Renderer { resources: Resources::new(), paths: Vec::new(), bg: None } } } @@ -590,6 +593,7 @@ impl Renderer { ctx.set_transform(view); let path = r.to_path(0.1); paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres); + self.bg = Some((fill, ph, view)); } fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { @@ -711,7 +715,16 @@ impl Renderer { } ctx.set_transform(m); ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); - paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres); + match (fl, &self.bg) { + // `useBgFill`: the slide's background, laid out on the slide (not the + // shape), shows through the shape. + (Fill::Background, Some((bg, ph, view))) => { + let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height); + let path = (view.inverse() * m) * sp.path.clone(); + paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres); + } + _ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres), + } ctx.set_fill_rule(peniko::Fill::NonZero); } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 9b7fa019..7f0ef965 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -572,3 +572,41 @@ fn a_gradient_with_a_stop_off_the_end_stays_linear() { assert!((got - want).abs() <= 4, "x {x}: {got} vs {want}"); } } + +#[test] +fn a_shape_filled_with_the_background_shows_the_slide_background() { + // `useBgFill`: PowerPoint paints the slide's background into the shape, laid out on the + // slide. A white panel covers a black-to-grey background; a shape on it with the background + // fill is a window onto that background. + use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop}; + let none = || Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }); + let panel = Shape { + id: ShapeId(710), + xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), + fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))), + line: none(), + ..Default::default() + }; + let window = Shape { + id: ShapeId(711), + xfrm: Some(Xfrm::new(100.0, 100.0, 760.0, 340.0)), + fill: Some(Fill::Background), + line: none(), + ..Default::default() + }; + let mut p = deck_with(vec![panel, window]); + let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) }; + std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { + fill: Fill::Gradient(Gradient { + stops: vec![stop(0.0, 0), stop(0.5, 0), stop(1.0, 200)], + shape: GradientShape::Linear { angle: 0.0, scaled: false }, + rotate_with_shape: true, + }), + }); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + assert_eq!(img.pixel(50, 50)[0], 255, "the panel stays white outside the window"); + assert!(img.pixel(300, 270)[0] < 8, "the window shows the background's black half: {:?}", img.pixel(300, 270)); + // Laid out on the slide: three quarters across the slide is half way from black to grey. + let v = img.pixel(720, 270)[0] as i32; + assert!((v - 100).abs() <= 6, "{v}"); +} From 39a8234633ef139c635b847baf5f4a57cd997664 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:45:31 -0400 Subject: [PATCH 12/49] Render: slide backgrounds are laid over white (#275) PowerPoint shows white around a background picture stretched into part of the slide and under a see-through background colour. DeckCraft left those areas transparent, which exports and the slide show draw black. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 4 ++++ crates/render/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e26f8c61..29f60c00 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -592,6 +592,10 @@ impl Renderer { let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height); ctx.set_transform(view); let path = r.to_path(0.1); + // PowerPoint lays every background over white: a picture stretched into part of the + // slide, or a see-through colour, leaves white around and under it. + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.fill_path(&path); paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres); self.bg = Some((fill, ph, view)); } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 7f0ef965..936dce3f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -610,3 +610,25 @@ fn a_shape_filled_with_the_background_shows_the_slide_background() { let v = img.pixel(720, 270)[0] as i32; assert!((v - 100).abs() <= 6, "{v}"); } + +#[test] +fn a_background_is_laid_over_white() { + // A background picture stretched into the bottom-right quarter (fillRect l=50% t=50%), and a + // half-transparent colour: PowerPoint shows white around and under them, not transparency. + use deckcraft_model::style::{Fill, PictureFill, PictureMode}; + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([0, 0, 200]))).write_to(&mut png, image::ImageFormat::Png).unwrap(); + let mut p = deck_with(vec![]); + let media = p.add_media("bg.png", "image/png", png.into_inner()); + let pf = PictureFill { media, mode: PictureMode::Stretch { fill_rect: [0.5, 0.5, 0.0, 0.0] }, ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::Picture(pf) }); + let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() }); + assert_eq!(img.pixel(20, 20), [255, 255, 255, 255], "white where the picture doesn't reach"); + let q = img.pixel(400, 230); + assert!(q[2] > 190 && q[0] < 10 && q[3] == 255, "{q:?}"); + let see_through = ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 }); + std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(see_through) }); + let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() }); + let g = img.pixel(20, 20); + assert!(g[3] == 255 && (g[0] as i32 - 127).abs() <= 3, "half-transparent black over white is grey: {g:?}"); +} From 8b160d5b03bda38e0d59af766dcfbb487a45b043 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:47:12 -0400 Subject: [PATCH 13/49] Text: wrap URLs, Korean and CJK punctuation where PowerPoint does (#276) * Text: wrap lines where PowerPoint does - Without latinLnBrk (PowerPoint's default) a word doesn't break after '/' or '\': URLs and paths fill the line and break at its edge. - Korean wraps between words, not between syllables. - With hangingPunct, closing East Asian punctuation hangs past the end of the line instead of dragging the previous character down. ParaProps gains latin_line_break and hanging_punct, read and written as latinLnBrk and hangingPunct. Co-Authored-By: Claude Opus 5.5 (1M context) * Text: simpler break guard (clippy) Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/model/src/text.rs | 6 ++++ crates/pptx/src/read/dml.rs | 2 ++ crates/pptx/src/write/dml.rs | 2 ++ crates/pptx/tests/roundtrip.rs | 14 ++++++++ crates/text/src/lib.rs | 61 ++++++++++++++++++++++++++++++-- crates/text/src/tests.rs | 63 ++++++++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 3 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index addbdff1..feaff468 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -223,6 +223,10 @@ pub struct ParaProps { pub rtl: Option, pub font_align: Option, pub east_asian_line_break: Option, + /// `latinLnBrk`: Latin words may break inside (after `/`, `\`); off by default. + pub latin_line_break: Option, + /// `hangingPunct`: closing East Asian punctuation may hang past the end of a line. + pub hanging_punct: Option, } impl ParaProps { @@ -253,6 +257,8 @@ impl ParaProps { self.font_align.clone_from(&p.font_align); } self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break); + self.latin_line_break = self.latin_line_break.or(p.latin_line_break); + self.hanging_punct = self.hanging_punct.or(p.hanging_punct); } } diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 01e08d7f..36f2a662 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -345,6 +345,8 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option rtl: p.bool("rtl"), font_align: p.attr("fontAlgn").map(String::from), east_asian_line_break: p.bool("eaLnBrk"), + latin_line_break: p.bool("latinLnBrk"), + hanging_punct: p.bool("hangingPunct"), default_tab: pt(p, "defTabSz"), ..Default::default() }; diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index eddb618e..23d33400 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -432,6 +432,8 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: .o("defTabSz", p.default_tab.map(|v| emu(v).max(0))) .b("rtl", p.rtl) .b("eaLnBrk", p.east_asian_line_break) + .b("latinLnBrk", p.latin_line_break) + .b("hangingPunct", p.hanging_punct) .o("fontAlgn", p.font_align.as_deref().filter(|f| matches!(*f, "auto" | "t" | "ctr" | "base" | "b"))); let has_kids = p.line_spacing.is_some() || p.space_before.is_some() diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index fddb0d97..66576e7a 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -766,3 +766,17 @@ fn a_picture_duotone_round_trips() { assert!(matches!(d.base, deckcraft_model::ColorBase::Scheme { slot: SchemeSlot::Accent1 })); assert!(matches!(l.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(250, 240, 200))); } + +#[test] +fn line_break_options_round_trip() { + let mut p = defaults::new_presentation(None); + let first = p.slides[0].id; + let s = p.slide_mut(first).expect("slide"); + let t = s.shapes[0].text.insert(TextBody::from_text("x")); + t.paragraphs[0].props.latin_line_break = Some(true); + t.paragraphs[0].props.hanging_punct = Some(false); + let q = round(&p); + let props = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].props; + assert_eq!(props.latin_line_break, Some(true)); + assert_eq!(props.hanging_punct, Some(false)); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 5eeb40c5..6918a8d7 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -718,8 +718,44 @@ fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) { (h * face.line_split, h * (1.0 - face.line_split)) } -/// Break opportunities: allowed[i] = a line may start at char i. -fn break_opportunities(text: &str) -> (Vec, Vec) { +/// Hangul syllables and jamo: PowerPoint wraps Korean at word boundaries (spaces), not between +/// syllables. +fn is_hangul(c: char) -> bool { + matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}') +} + +/// Closing East Asian punctuation that may hang past the end of a line (`hangingPunct`). +fn is_hanging_punct(c: char) -> bool { + matches!( + c, + '、' | '。' + | ',' + | '.' + | ':' + | ';' + | '!' + | '?' + | ')' + | '」' + | '』' + | '】' + | '〕' + | '〉' + | '》' + | '〙' + | '〛' + | '}' + | ']' + | '」' + | '、' + | '。' + ) +} + +/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then +/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and +/// Korean breaks only between words. +fn break_opportunities(text: &str, latin_break: bool) -> (Vec, Vec) { let n = text.chars().count(); let mut allowed = vec![false; n + 1]; let mut mandatory = vec![false; n + 1]; @@ -740,6 +776,17 @@ fn break_opportunities(text: &str) -> (Vec, Vec) { } } } + let chars: Vec = text.chars().collect(); + for (i, w) in chars.windows(2).enumerate() { + let (prev, next) = (w[0], w[1]); + let keep = if is_hangul(prev) && is_hangul(next) { false } else { latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() }; + if !keep + && !mandatory.get(i + 1).copied().unwrap_or(false) + && let Some(a) = allowed.get_mut(i + 1) + { + *a = false; + } + } (allowed, mandatory) } @@ -761,7 +808,8 @@ fn break_lines( x0_rest: f64, ) -> Vec { let text: String = p.cells.iter().map(|c| c.ch).collect(); - let (allowed, _) = break_opportunities(&text); + let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false)); + let hang = p.props.hanging_punct.unwrap_or(false); let n = p.cells.len(); let mut lines = vec![]; let mut start = 0; @@ -788,6 +836,13 @@ fn break_lines( if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) { last_break = Some(i); } + // Closing East Asian punctuation hangs past the end instead of wrapping + // (`hangingPunct`); the line breaks after it. + if wrap && hang && x + adv > avail + 0.01 && x <= avail + 0.01 && is_hanging_punct(c) && i > start { + end = i + 1; + next = i + 1; + break; + } if wrap && x + adv > avail + 0.01 && !c.is_whitespace() && i > start { // Break at the last opportunity, or force mid-word (between clusters). let b = last_break diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 09595764..f8011417 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -520,3 +520,66 @@ fn single_spaced_lines_are_1_2_times_the_size_apart_whatever_the_font() { assert!((first.baseline - first.top - size * LINE_PITCH * face.line_split).abs() < 1e-6); } } + +/// The lines of `text` in an Arial-free (whatever face resolves) box `w` wide, as strings. +fn wrapped(text: &str, w: f64, set: impl Fn(&mut ParaProps)) -> Vec { + let (p, mut sh) = bidi_setup(vec![Run::new(text)], w, false, Align::Left); + let t = sh.text.as_mut().unwrap(); + t.body.inset_l = Some(0.0); + t.body.inset_r = Some(0.0); + set(&mut t.paragraphs[0].props); + let l = lay(&p, &sh, w, 600.0); + let chars: Vec = text.chars().collect(); + l.lines.iter().map(|li| chars[li.start..li.end].iter().collect()).collect() +} + +/// Width of `text` on one line. +fn one_line(text: &str) -> f64 { + let (p, sh) = bidi_setup(vec![Run::new(text)], 5000.0, false, Align::Left); + let l = lay(&p, &sh, 5000.0, 300.0); + l.lines[0].caret_x.last().unwrap() - l.lines[0].caret_x[0] +} + +#[test] +fn a_word_does_not_break_after_a_slash_or_backslash() { + // PowerPoint (latinLnBrk off, its default) keeps "alpha/beta/gamma" whole and only breaks it + // where it no longer fits; Unicode line breaking would allow a break after each slash. + for sep in ["/", "\\"] { + let text = ["alpha", "beta", "gamma", "delta", "epsilon"].join(sep); + let w = one_line(&text) * 0.6; + let lines = wrapped(&text, w, |_| {}); + assert!(lines.len() >= 2, "{lines:?}"); + assert!(lines[0].chars().count() > "alpha/beta".len(), "filled to the edge, not cut at a separator: {lines:?}"); + } + // latinLnBrk on: Unicode's break after a slash is allowed again. + let text = "alpha/beta/gamma/delta/epsilon"; + let lines = wrapped(text, one_line(text) * 0.6, |p| p.latin_line_break = Some(true)); + assert!(lines[0].ends_with('/'), "{lines:?}"); + // A slash followed by a space still breaks, and hyphens still break. + let lines = wrapped("alpha-beta-gamma-delta-epsilon", one_line("alpha-beta-gamma-") + 1.0, |_| {}); + assert_eq!(lines[0], "alpha-beta-gamma-"); +} + +#[test] +fn korean_wraps_between_words() { + let text = "한국어 안녕하세요 반갑습니다 감사합니다"; + let w = one_line("한국어 안녕하세요 반") + 0.5; + let lines = wrapped(text, w, |_| {}); + assert_eq!(lines.iter().map(|l| l.trim_end()).collect::>(), ["한국어 안녕하세요", "반갑습니다", "감사합니다"], "{lines:?}"); + // Japanese and Chinese still break between characters. + let lines = wrapped("日本語の文章は空白なしで続きます", one_line("日本語の文章") + 0.5, |_| {}); + assert_eq!(lines[0], "日本語の文章"); +} + +#[test] +fn closing_punctuation_hangs_past_the_end_with_hanging_punct() { + // PowerPoint (hangingPunct on, as its default text styles set it) lets "。" hang past the + // edge instead of pulling the character before it onto the next line. + let text = "白なしで続きます。句読点も含めて。"; + let w = one_line("白なしで続きます") + 0.5; + let lines = wrapped(text, w, |p| p.hanging_punct = Some(true)); + assert_eq!(lines[0], "白なしで続きます。", "{lines:?}"); + // Without it, "。" can't start a line, so "す。" moves down together. + let lines = wrapped(text, w, |p| p.hanging_punct = Some(false)); + assert_eq!(lines[0], "白なしで続きま", "{lines:?}"); +} From ba56742fa9ad75264d9e8f237651f857eba2a266 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:48:51 -0400 Subject: [PATCH 14/49] Emoji: drawn in colour, with families, skin tones and flags joined (#278) * Emoji: colour glyphs, and emoji take the colour emoji font Emoji were drawn as black outlines from a symbol font, with ZWJ sequences (families, flags) falling apart into their parts. Now: - an emoji cluster (emoji presentation, U+FE0F, ZWJ between pictographs, skin tones, regional indicators, keycaps) takes an installed colour font (COLR, CBDT, sbix or SVG tables) before the document's fonts, so its sequences ligate as in PowerPoint; U+FE0E keeps a plain face; - COLR v0 and v1 glyphs are painted in their CPAL colours (solid fills, linear, radial and sweep gradients, transforms, composite modes), with palette index 0xFFFF in the text colour. A tiny COLRv0 test font (two squares, CC0, regenerated by make_colr_font.py) backs the render test. Co-Authored-By: Claude Opus 5.5 (1M context) * Render: lock skrifa for colour glyphs Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ATTRIBUTION.md | 2 + Cargo.lock | 1 + crates/fonts/src/fallback.rs | 35 +++ crates/fonts/src/fontdb.rs | 70 ++++++ crates/fonts/src/lib.rs | 21 +- crates/render/Cargo.toml | 1 + crates/render/src/colr.rs | 212 ++++++++++++++++++ crates/render/src/lib.rs | 8 +- crates/render/src/tests.rs | 26 +++ crates/render/tests/fixtures/colr-squares.ttf | Bin 0 -> 892 bytes .../render/tests/fixtures/make_colr_font.py | 51 +++++ crates/text/src/lib.rs | 4 +- crates/text/src/tests.rs | 19 ++ 13 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 crates/render/src/colr.rs create mode 100644 crates/render/tests/fixtures/colr-squares.ttf create mode 100644 crates/render/tests/fixtures/make_colr_font.py diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c8137dfb..b0d7747e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,6 +27,8 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene | `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests | | `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests | | `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures | +| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests | +| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font | ## Bundled through dependencies diff --git a/Cargo.lock b/Cargo.lock index 8518da8f..65028e5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1108,6 +1108,7 @@ dependencies = [ "image", "kurbo", "log", + "skrifa 0.47.0", "vello_common 0.2.0", "vello_cpu 0.2.0", ] diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs index 99eb5baf..f4e1100c 100644 --- a/crates/fonts/src/fallback.rs +++ b/crates/fonts/src/fallback.rs @@ -24,6 +24,22 @@ fn ignorable(c: char) -> bool { || matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF) } +/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation, or +/// any sequence with an emoji presentation selector (U+FE0F), a zero-width joiner between +/// pictographs, a skin tone modifier, regional indicators (flags) or a keycap? +pub fn is_emoji_cluster(cluster: &[char]) -> bool { + if cluster.contains(&'\u{FE0E}') { + return false; + } + let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF); + let default_emoji = + |c: char| matches!(c as u32, 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x1FA70..=0x1FAFF | 0x1F1E6..=0x1F1FF); + cluster.iter().any(|&c| default_emoji(c)) + || (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c) || c.is_ascii_digit() || c == '#' || c == '*')) + || (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c))) + || cluster.contains(&'\u{20E3}') +} + /// Does `face` draw every character of the cluster? pub fn covers_cluster(face: &FontFace, cluster: &[char]) -> bool { cluster.iter().all(|c| ignorable(*c) || face.covers(*c)) @@ -55,9 +71,28 @@ impl FontDb { /// bundled face for its script, else an installed face for the script, else any loaded /// face that draws it, else `chain[0]` (or the default face if `chain` is empty). pub fn cascade(&self, cluster: &[char], chain: &[Arc], style: &str) -> Arc { + // Emoji take a colour face (with its ZWJ sequences, skin tones and flags) before the + // document's fonts, as PowerPoint draws them, even when a text font has a plain glyph. + if is_emoji_cluster(cluster) { + if let Some(f) = chain.iter().find(|f| f.is_color() && covers_cluster(f, cluster)) { + return f.clone(); + } + if let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) + && let Some(f) = self.color_face_for(key, cluster) + { + return f; + } + } if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) { return f.clone(); } + // Text presentation (U+FE0E): a plain face, not a colour emoji one. + if cluster.contains(&'\u{FE0E}') { + let faces = self.read_faces(); + if let Some(f) = faces.iter().find(|f| !f.is_color() && covers_cluster(f, cluster)) { + return f.clone(); + } + } let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style)); let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() }; let script = cluster.iter().find_map(|c| script_of(*c)); diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 8b2a1ec8..d9c9ece7 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -91,6 +91,8 @@ pub struct FontFace { pub instance: Option, /// Basic Multilingual Plane coverage bitset, built on first use. bmp: std::sync::OnceLock>, + /// Colour glyph tables present (`is_color`), looked up on first use. + color: std::sync::OnceLock, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -158,6 +160,11 @@ impl FontFace { pub fn skrifa(&self) -> Option> { skrifa::FontRef::from_index(self.data(), self.index).ok() } + /// Does the face draw colour glyphs (a `COLR`, `CBDT`, `sbix` or `SVG ` table, as colour emoji + /// fonts have)? + pub fn is_color(&self) -> bool { + *self.color.get_or_init(|| has_color_tables(self.data(), self.index)) + } pub fn hb(&self) -> Option> { harfrust::FontRef::from_index(self.data(), self.index).ok() } @@ -258,6 +265,13 @@ struct SysFallback { misses: std::collections::HashSet, } +/// Does face `index` of `data` carry colour glyph tables? +fn has_color_tables(data: &[u8], index: u32) -> bool { + use skrifa::raw::types::Tag; + let Ok(f) = skrifa::FontRef::from_index(data, index) else { return false }; + [b"COLR", b"CBDT", b"sbix", b"SVG "].iter().any(|t| f.table_data(Tag::new(t)).is_some()) +} + /// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji. #[cfg(not(target_arch = "wasm32"))] const SYSTEM_FALLBACKS: &[&str] = &[ @@ -496,6 +510,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords bytes, index, bmp: std::sync::OnceLock::new(), + color: std::sync::OnceLock::new(), }) } @@ -902,6 +917,61 @@ impl FontDb { false } + /// A loaded colour face drawing all of `cluster`, loading an installed colour font that covers + /// `key` if none is loaded yet. Misses are remembered. + pub(crate) fn color_face_for(&self, key: char, cluster: &[char]) -> Option> { + let pick = |db: &FontDb| { + let faces = db.read_faces(); + let mut v: Vec<&Arc> = faces.iter().filter(|f| crate::covers_cluster(f, cluster) && f.is_color()).collect(); + v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32)); + v.first().map(|f| (*f).clone()) + }; + if let Some(f) = pick(self) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.system_color_fallback(key) { + return pick(self); + } + let _ = key; + None + } + + /// Load an installed colour font covering `c`. True if one was loaded. + #[cfg(not(target_arch = "wasm32"))] + fn system_color_fallback(&self, c: char) -> bool { + static MISSES: Mutex>> = Mutex::new(None); + if c.is_control() || c.is_whitespace() { + return false; + } + { + let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + if !sys.enabled { + return false; + } + } + if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) { + return false; + } + let mut paths: Vec = self.read_catalog().iter().map(|e| e.path.clone()).collect(); + paths.sort(); + paths.dedup(); + for p in paths { + if std::fs::metadata(&p).map(|m| m.len() > 64 << 20).unwrap_or(true) { + continue; + } + let Ok(data) = std::fs::read(&p) else { continue }; + let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| { + has_color_tables(&data, *i) && skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()) + }); + if hit && self.add_font(data) > 0 { + return true; + } + } + MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c); + false + } + /// Glyph outline in font units, y-down (flipped), cached per (face, glyph). pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { let key = (face.id, gid); diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 74ce9211..8caff942 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -13,7 +13,7 @@ mod fontdb; pub mod script; mod shape; -pub use fallback::{bundled_families, covers_cluster, ligature_carets}; +pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets}; pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs}; pub use harfrust::Feature; use harfrust::Tag; @@ -136,6 +136,25 @@ mod tests { FontDb::global().face(DEFAULT_FAMILY, "Regular") } + #[test] + fn emoji_clusters_are_recognised() { + let c = |s: &str| s.chars().collect::>(); + for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "1\u{FE0F}\u{20E3}", "🏳\u{FE0F}\u{200D}🌈"] { + assert!(is_emoji_cluster(&c(yes)), "{yes:?}"); + } + for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] { + assert!(!is_emoji_cluster(&c(no)), "{no:?}"); + } + } + + #[test] + fn colour_fonts_are_recognised() { + let db = FontDb::with_font_dirs(Vec::new()); + assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0); + assert!(db.face("DeckCraft Colr Squares", "Regular").is_color()); + assert!(!fontdb::last_resort_face().is_color()); + } + #[test] fn last_resort_face_parses() { let f = fontdb::last_resort_face(); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 6eecf625..ccb98ea1 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -19,6 +19,7 @@ vello_common = { workspace = true } image = { workspace = true } log = { workspace = true } kurbo = { workspace = true } +skrifa = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] vello_cpu = { workspace = true, features = ["multithreading"] } diff --git a/crates/render/src/colr.rs b/crates/render/src/colr.rs new file mode 100644 index 00000000..97294aa8 --- /dev/null +++ b/crates/render/src/colr.rs @@ -0,0 +1,212 @@ +//! Colour glyphs (OpenType `COLR` v0 and v1, colours from `CPAL`), as colour emoji fonts draw +//! them: layers of glyph outlines filled with solid colours or gradients. + +use deckcraft_color::Rgba; +use deckcraft_fonts::FontFace; +use kurbo::{Affine, BezPath, Point, Rect}; +use skrifa::color::{Brush, ColorPainter, ColorStop, CompositeMode, Extend, Transform}; +use skrifa::raw::types::BoundingBox; +use skrifa::{GlyphId, MetadataProvider}; +use vello_cpu::{RenderContext, peniko}; + +/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts). +const MAX_DEPTH: usize = 64; + +/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale` +/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all. +/// False when the glyph has no colour version, so the caller draws the plain outline. +pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, scale: f64, fg: Rgba, alpha: f64) -> bool { + let Some(font) = face.skrifa() else { return false }; + let glyphs = font.color_glyphs(); + let Some(glyph) = glyphs.get(GlyphId::new(gid)) else { return false }; + let palette: Vec = font + .color_palettes() + .get(0) + .map(|p| p.colors().iter().map(|c| Rgba { r: c.red, g: c.green, b: c.blue, a: c.alpha }).collect()) + .unwrap_or_default(); + // Font units are y up; the slide is y down. + let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0); + let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() }; + p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None); + let ok = glyph.paint(face.location(), &mut p).is_ok(); + // A malformed glyph may leave layers open: close them all. + while let Some(open) = p.opened.pop() { + if open { + p.ctx.pop_layer(); + } + } + p.ctx.pop_layer(); + ok +} + +struct Painter<'a> { + ctx: &'a mut RenderContext, + face: &'a FontFace, + base: Affine, + /// Transforms pushed by the glyph (font units), innermost last. + stack: Vec, + palette: Vec, + fg: Rgba, + /// Clips and composite groups pushed by the glyph, innermost last: true when a layer was + /// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too). + opened: Vec, +} + +fn affine(t: &Transform) -> Affine { + Affine::new([t.xx as f64, t.yx as f64, t.xy as f64, t.yy as f64, t.dx as f64, t.dy as f64]) +} + +fn pt(p: skrifa::raw::types::Point) -> Point { + Point::new(p.x as f64, p.y as f64) +} + +fn extend(e: Extend) -> peniko::Extend { + match e { + Extend::Repeat => peniko::Extend::Repeat, + Extend::Reflect => peniko::Extend::Reflect, + _ => peniko::Extend::Pad, + } +} + +fn blend(m: CompositeMode) -> peniko::BlendMode { + use peniko::{Compose, Mix}; + let (mix, compose) = match m { + CompositeMode::Clear => (Mix::Normal, Compose::Clear), + CompositeMode::Src => (Mix::Normal, Compose::Copy), + CompositeMode::Dest => (Mix::Normal, Compose::Dest), + CompositeMode::DestOver => (Mix::Normal, Compose::DestOver), + CompositeMode::SrcIn => (Mix::Normal, Compose::SrcIn), + CompositeMode::DestIn => (Mix::Normal, Compose::DestIn), + CompositeMode::SrcOut => (Mix::Normal, Compose::SrcOut), + CompositeMode::DestOut => (Mix::Normal, Compose::DestOut), + CompositeMode::SrcAtop => (Mix::Normal, Compose::SrcAtop), + CompositeMode::DestAtop => (Mix::Normal, Compose::DestAtop), + CompositeMode::Xor => (Mix::Normal, Compose::Xor), + CompositeMode::Plus => (Mix::Normal, Compose::Plus), + CompositeMode::Screen => (Mix::Screen, Compose::SrcOver), + CompositeMode::Overlay => (Mix::Overlay, Compose::SrcOver), + CompositeMode::Darken => (Mix::Darken, Compose::SrcOver), + CompositeMode::Lighten => (Mix::Lighten, Compose::SrcOver), + CompositeMode::ColorDodge => (Mix::ColorDodge, Compose::SrcOver), + CompositeMode::ColorBurn => (Mix::ColorBurn, Compose::SrcOver), + CompositeMode::HardLight => (Mix::HardLight, Compose::SrcOver), + CompositeMode::SoftLight => (Mix::SoftLight, Compose::SrcOver), + CompositeMode::Difference => (Mix::Difference, Compose::SrcOver), + CompositeMode::Exclusion => (Mix::Exclusion, Compose::SrcOver), + CompositeMode::Multiply => (Mix::Multiply, Compose::SrcOver), + CompositeMode::HslHue => (Mix::Hue, Compose::SrcOver), + CompositeMode::HslSaturation => (Mix::Saturation, Compose::SrcOver), + CompositeMode::HslColor => (Mix::Color, Compose::SrcOver), + CompositeMode::HslLuminosity => (Mix::Luminosity, Compose::SrcOver), + _ => (Mix::Normal, Compose::SrcOver), + }; + peniko::BlendMode::new(mix, compose) +} + +impl Painter<'_> { + fn current(&self) -> Affine { + self.base * self.stack.last().copied().unwrap_or(Affine::IDENTITY) + } + + fn color(&self, index: u16, alpha: f32) -> peniko::Color { + let c = if index == 0xFFFF { self.fg } else { self.palette.get(index as usize).copied().unwrap_or(self.fg) }; + let a = (c.a as f64 * (alpha as f64).clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8; + peniko::Color::from_rgba8(c.r, c.g, c.b, a) + } + + fn stops(&self, stops: &[ColorStop]) -> Vec { + stops.iter().map(|s| peniko::ColorStop { offset: s.offset.clamp(0.0, 1.0), color: self.color(s.palette_index, s.alpha).into() }).collect() + } + + /// The glyph outline in font units, y up. + fn outline(&self, gid: GlyphId) -> BezPath { + let p = deckcraft_fonts::FontDb::global().outline(self.face, gid.to_u32()); + Affine::scale_non_uniform(1.0, -1.0) * (*p).clone() + } + + fn push_clip(&mut self, path: &BezPath) { + let open = self.opened.len() < MAX_DEPTH; + if open { + self.ctx.set_transform(self.current()); + self.ctx.push_clip_layer(path); + } + self.opened.push(open); + } + + fn pop(&mut self) { + if self.opened.pop() == Some(true) { + self.ctx.pop_layer(); + } + } + + /// Fill the open clip area (a large box in the current transform) with `paint`. + fn cover(&mut self, paint: impl Into) { + self.ctx.set_transform(self.current()); + self.ctx.set_paint(paint); + self.ctx.fill_rect(&Rect::new(-1.0e5, -1.0e5, 1.0e5, 1.0e5)); + } +} + +impl ColorPainter for Painter<'_> { + fn push_transform(&mut self, t: Transform) { + let next = self.stack.last().copied().unwrap_or(Affine::IDENTITY) * affine(&t); + self.stack.push(next); + } + + fn pop_transform(&mut self) { + if self.stack.len() > 1 { + self.stack.pop(); + } + } + + fn push_clip_glyph(&mut self, gid: GlyphId) { + let path = self.outline(gid); + self.push_clip(&path); + } + + fn push_clip_box(&mut self, b: BoundingBox) { + let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64); + self.push_clip(&kurbo::Shape::to_path(&r, 0.1)); + } + + fn pop_clip(&mut self) { + self.pop(); + } + + fn fill(&mut self, brush: Brush<'_>) { + match brush { + Brush::Solid { palette_index, alpha } => { + let c = self.color(palette_index, alpha); + self.cover(c); + } + Brush::LinearGradient { p0, p1, color_stops, extend: e } => { + let g = peniko::Gradient::new_linear(pt(p0), pt(p1)).with_stops(self.stops(color_stops).as_slice()).with_extend(extend(e)); + self.cover(g); + } + Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => { + let g = peniko::Gradient::new_two_point_radial(pt(c0), r0.max(0.0), pt(c1), r1.max(0.0)) + .with_stops(self.stops(color_stops).as_slice()) + .with_extend(extend(e)); + self.cover(g); + } + Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => { + let g = peniko::Gradient::new_sweep(pt(c0), start_angle.to_radians(), end_angle.to_radians()) + .with_stops(self.stops(color_stops).as_slice()) + .with_extend(extend(e)); + self.cover(g); + } + } + } + + fn push_layer(&mut self, mode: CompositeMode) { + let open = self.opened.len() < MAX_DEPTH; + if open { + self.ctx.push_blend_layer(blend(mode)); + } + self.opened.push(open); + } + + fn pop_layer(&mut self) { + self.pop(); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 29f60c00..33bec34b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] mod chart; +mod colr; mod images; mod morph_path; mod morph_text; @@ -1147,11 +1148,16 @@ pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { return; } + let k = run.size / run.face.upem.max(1.0); + // Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still + // strokes the plain glyph below. + if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) { + return; + } let path = glyph(&run.face, gid); if path.elements().is_empty() { return; } - let k = run.size / run.face.upem.max(1.0); let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; ctx.set_transform(at * skew * Affine::scale(k)); if alpha > 0.0 && run.color.a > 0 { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 936dce3f..15f70167 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -632,3 +632,29 @@ fn a_background_is_laid_over_white() { let g = img.pixel(20, 20); assert!(g[3] == 255 && (g[0] as i32 - 127).abs() <= 3, "half-transparent black over white is grey: {g:?}"); } + +#[test] +fn colour_glyphs_paint_their_layers() { + // A COLRv0 test font (crates/render/tests/fixtures): U+E000 is a red square with a blue + // square in its middle, U+E001 a square in the text colour. + deckcraft_fonts::FontDb::global().add_font(include_bytes!("../tests/fixtures/colr-squares.ttf").to_vec()); + let mut sh = Shape { id: ShapeId(720), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), text_box: true, ..Default::default() }; + let mut t = TextBody::from_text("\u{E000} \u{E001}"); + let props = &mut t.paragraphs[0].runs[0].props; + props.size = Some(72.0); + props.font = Some("DeckCraft Colr Squares".into()); + props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 160, 0)))); + sh.text = Some(t); + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let count = |f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count(); + let red = count(&|p| p[0] > 230 && p[1] < 30 && p[2] < 30); + let blue = count(&|p| p[0] < 30 && p[1] < 30 && p[2] > 230); + let green = count(&|p| p[0] < 30 && p[1] > 140 && p[1] < 180 && p[2] < 30); + // 72 pt squares: the blue one is 40 % of the side (16 % of the area). + assert!(red > 2500 && blue > 500, "red {red} blue {blue}"); + let share = blue as f64 / (red + blue) as f64; + assert!((share - 0.16).abs() < 0.03, "blue share {share}"); + // The text-colour glyph is as big as the other square. + assert!((green as f64 / (red + blue) as f64 - 1.0).abs() < 0.1, "green {green} vs {}", red + blue); +} diff --git a/crates/render/tests/fixtures/colr-squares.ttf b/crates/render/tests/fixtures/colr-squares.ttf new file mode 100644 index 0000000000000000000000000000000000000000..546942b00940464791059c92ce437d70b50b0382 GIT binary patch literal 892 zcmb7CL2DC16#iyslPHwbN-aeY)>6bnz_y2aFk)pZs1z#65722cNlIp$bdx}P>mR7} z57dJwcoa__3SN5Ci~k^sNa(RBco5fbCP`WGAU^iZyzhH&-+MD}AOK8Y4-zZk`dSG& z+y-Qk?6vUG-8Cf1MdB*)%zF98(mmX~1c>{@Pb!gWzdH>MiQf_z>t?t1{P+cJ&nK=n zw5l$A&R(befx6rvk^3l57;pXaji|dL2ecqwamkxjMd2Y9h`(7Jshu{?Axms?1Y0W7 zFZbWvV*Up4e!JD_&P{%KO8kR(74|IDWBvZw z@@%!4(jr{$EPS`KVA|P|bG7|W zv$nmbVV9oGm?XqOpF>PO1)hr{F_H<)VjfFG?zHY!m~V0F>XfyA&i+3y3Zmd0c}KP{ z!92tVR)4iw#mDlf1A&=wRo-Ndh$ryR(_6rmk?kRm2dlc$3}aR624Ty@L3z8UV%;g} zde5ks1J|gai4ZXqYUuK!LbPCr1C+6i9{HG29hA7%iH&1e{qG5qObK5dr0|3kzR2Wa wUEMG}EjPECa$~ce=|svHM%XNQ>ScCqZO`q(y@CwS p.clone(), _ => { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f8011417..cd78b903 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -583,3 +583,22 @@ fn closing_punctuation_hangs_past_the_end_with_hanging_punct() { let lines = wrapped(text, w, |p| p.hanging_punct = Some(false)); assert_eq!(lines[0], "白なしで続きま", "{lines:?}"); } + +#[test] +fn emoji_take_a_colour_face_when_one_is_installed() { + // PowerPoint draws emoji (and ZWJ sequences, skin tones, flags) from the colour emoji font + // even when the run's font has a plain glyph for some of them. Skipped without an installed + // colour emoji font. + let db = FontDb::global(); + let probe = db.cascade(&['😀'], &[], "Regular"); + if !probe.is_color() { + return; + } + for s in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧"] { + let (p, sh) = bidi_setup(vec![Run::new(format!("a{s}b"))], 2000.0, false, Align::Left); + let l = lay(&p, &sh, 2000.0, 300.0); + let emoji = l.runs.iter().find(|r| r.cells.iter().any(|(_, c)| !c.is_ascii())).unwrap(); + assert!(emoji.face.is_color(), "{s:?} drawn with {}", emoji.face.family); + assert!(l.runs.iter().filter(|r| r.cells.iter().all(|(_, c)| c.is_ascii())).all(|r| !r.face.is_color()), "the letters keep a text face"); + } +} From 1bb86db54aedb89f3657852032938fc759dea568 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:51:20 -0400 Subject: [PATCH 15/49] Text: gradient fills draw, starting over on every line (#281) A gradient on text was read but drawn in its first colour. Each line is now filled with the whole gradient, spanning that line's text from its top to its bottom and its first to its last character, as PowerPoint does. The gradient builder moves out of fill_path into paint::gradient so fills and text share it (and PowerPoint's two-colour ramp). Gradient colours are resolved when the text style is made. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 58 ++++++++++++++++++++++++++++++++++-- crates/render/src/paint.rs | 61 +++++++++++++++++++++----------------- crates/render/src/tests.rs | 43 +++++++++++++++++++++++++++ crates/text/src/lib.rs | 9 +++++- 4 files changed, 141 insertions(+), 30 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 33bec34b..3296a1c2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1133,8 +1133,24 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, continue; } let m = m * Affine::translate(ps.offset); + // A gradient fill starts over on every line and spans that line's text (from its top to + // its bottom, from its first to its last character), as in PowerPoint. + let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new(); for (gid, x, y) in &run.glyphs { - draw_glyph(ctx, run, *gid, m * Affine::translate((*x, *y)), ps.opacity); + let at = m * Affine::translate((*x, *y)); + let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else { + draw_glyph(ctx, run, *gid, at, ps.opacity); + continue; + }; + let li = line_at(l, run.para, *y); + if !grads.iter().any(|(i, _)| *i == li) { + let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] }; + let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect(); + let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr); + grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity))); + } + let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y)))); + draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill); } } for d in l.decos.iter().filter(|d| !d.behind) { @@ -1142,8 +1158,39 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, } } +/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline). +fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize { + l.lines + .iter() + .enumerate() + .filter(|(_, li)| li.para == para) + .min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs())) + .map(|(i, _)| i) + .unwrap_or(0) +} + +/// The box a line's text covers: its top and bottom, its first and last caret. +fn line_bounds(li: &deckcraft_text::LineInfo) -> Option { + let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x))); + let r = Rect::new(x0, li.top, x1, li.bottom); + (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r) +} + /// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`. pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) { + draw_glyph_with(ctx, run, gid, at, opacity, None); +} + +/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from +/// the glyph's origin to that space) instead of the run's colour. +fn draw_glyph_with( + ctx: &mut RenderContext, + run: &deckcraft_text::GlyphRun, + gid: u32, + at: Affine, + opacity: f64, + fill: Option<(&peniko::Gradient, Affine)>, +) { let alpha = run.alpha * opacity; if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { return; @@ -1160,7 +1207,14 @@ pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun } let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; ctx.set_transform(at * skew * Affine::scale(k)); - if alpha > 0.0 && run.color.a > 0 { + if let Some((g, to_text)) = fill + && alpha > 0.0 + { + ctx.set_paint(g.clone()); + ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text); + ctx.fill_path(&path); + ctx.reset_paint_transform(); + } else if alpha > 0.0 && run.color.a > 0 { ctx.set_paint(color(run.color, alpha)); ctx.fill_path(&path); if run.fake_bold { diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 365178aa..18822467 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -35,33 +35,8 @@ pub fn fill_path( if g.stops.is_empty() { return; } - let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect(); - stops.sort_by(|a, b| a.0.total_cmp(&b.0)); - // PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`. - let stops: Vec = - crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect(); - let grad = match &g.shape { - GradientShape::Linear { angle, .. } => { - // Angle measured clockwise from left→right; span the bounds along that direction. - let a = angle.to_radians(); - let (dx, dy) = (a.cos(), a.sin()); - let c = bounds.center(); - let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0; - peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half)) - .with_stops(stops.as_slice()) - } - GradientShape::Path { focus, .. } => { - let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0; - let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0; - // Reach the farthest corner, so a gradient from a corner covers the whole box. - let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)] - .iter() - .map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt()) - .fold(1e-6, f64::max); - peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice()) - } - }; - ctx.set_paint(grad); + let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect(); + ctx.set_paint(gradient(&g.shape, stops, bounds, alpha)); ctx.fill_path(path); } Fill::Picture(pf) => { @@ -131,6 +106,38 @@ pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option) -> std::b std::borrow::Cow::Owned(out) } +/// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded +/// by `alpha`, ramping between stops as PowerPoint does (see `ramp`). +pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient { + for s in &mut stops { + s.0 = s.0.clamp(0.0, 1.0); + } + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + let stops: Vec = + crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect(); + match shape { + GradientShape::Linear { angle, .. } => { + // Angle measured clockwise from left→right; span the bounds along that direction. + let a = angle.to_radians(); + let (dx, dy) = (a.cos(), a.sin()); + let c = bounds.center(); + let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0; + peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half)) + .with_stops(stops.as_slice()) + } + GradientShape::Path { focus, .. } => { + let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0; + let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0; + // Reach the farthest corner, so a gradient from a corner covers the whole box. + let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)] + .iter() + .map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt()) + .fold(1e-6, f64::max); + peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice()) + } + } +} + /// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated /// GIF shows its frame at `gif_time` seconds. pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option) { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 15f70167..d739e775 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -658,3 +658,46 @@ fn colour_glyphs_paint_their_layers() { // The text-colour glyph is as big as the other square. assert!((green as f64 / (red + blue) as f64 - 1.0).abs() < 0.1, "green {green} vs {}", red + blue); } + +#[test] +fn a_text_gradient_starts_over_on_every_line() { + // PowerPoint fills each line of gradient text with the whole gradient, spanning that line + // (black at the top of every line to white at its bottom), not the text box. + use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop}; + let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) }; + let mut sh = Shape { id: ShapeId(730), xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 500.0)), text_box: true, ..Default::default() }; + let mut t = TextBody::from_text("HHHH\nHHHH"); + for p in &mut t.paragraphs { + let props = &mut p.runs[0].props; + props.size = Some(80.0); + props.fill = Some(Fill::Gradient(Gradient { + stops: vec![stop(0.0, 0), stop(1.0, 255)], + shape: GradientShape::Linear { angle: 90.0, scaled: false }, + rotate_with_shape: true, + })); + } + sh.text = Some(t); + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + // Ink rows (any pixel not white), grouped into the two lines. + let ink = |y: u32| (0..600).any(|x| img.pixel(x, y)[0] < 250); + let mut bands: Vec<(u32, u32)> = vec![]; + for y in 0..500 { + if ink(y) { + match bands.last_mut() { + Some(b) if b.1 + 1 == y => b.1 = y, + _ => bands.push((y, y)), + } + } + } + assert_eq!(bands.len(), 2, "{bands:?}"); + // The darkest ink in the first rows of each line. + let top = |b: (u32, u32)| (b.0..b.0 + 6).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap(); + let (a, b) = (top(bands[0]), top(bands[1])); + assert!(a < 140 && (a as i32 - b as i32).abs() < 25, "line tops {a} and {b}: the second line starts dark again"); + // ...and each line ends light. + let bottom = |b: (u32, u32)| (b.1 - 5..=b.1).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap(); + for band in bands { + assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band)); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index d91e146d..32bfdc42 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -245,7 +245,14 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> Styl let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0); let (mut color, alpha, gradient) = match &r.fill { Some(Fill::Solid { color }) => (ctx.color(color, None), color.alpha(), None), - Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g.clone())), + Some(Fill::Gradient(g)) => { + // Colours resolved here (theme, colour map), so the renderer needs no colour scheme. + let mut g = g.clone(); + for s in &mut g.stops { + s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None)); + } + (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g)) + } Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None), _ => (resolve::text_color(ctx, r), 1.0, None), }; From 2bf62c97e22ba0c03a3a43c95ad2524d9a87abe3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:53:17 -0400 Subject: [PATCH 16/49] Text: shadows and glows are drawn (#283) Run shadows and glows (a:rPr/a:effectLst outerShdw, glow) were read but never drawn. The text layout now carries them on each glyph run (colours resolved, sizes scaled with autofit) and the renderer draws every run's shadow, then every glow, blurred, under the text. A text glow is solid to about a third of its radius past the letters and fades out by two thirds, as measured on PowerPoint's export. needs_filters also looks at text effects (runs, list styles, master text styles), so they render with the blur filter. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 68 +++++++++++++++++++++++++++++++++++++- crates/render/src/tests.rs | 49 +++++++++++++++++++++++++++ crates/text/src/lib.rs | 33 ++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3296a1c2..9a63af75 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -248,7 +248,7 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool { return; } let (e, _) = resolve::effects(&rctx, s); - if e.is_some_and(|e| !e.is_empty()) { + if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) { any = true; } }); @@ -261,6 +261,20 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool { any } +/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's +/// text styles it inherits)? Such text is blurred, which needs filters. +fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool { + use deckcraft_model::text::{ListStyle, RunProps}; + let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0); + let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run)); + let Some(t) = &s.text else { return false }; + t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props)) + || styled(&t.list_style) + || styled(&rctx.master.title_style) + || styled(&rctx.master.body_style) + || styled(&rctx.master.other_style) +} + /// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a /// blank image (logged), never a crash. pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image { @@ -1124,6 +1138,15 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, for d in l.decos.iter().filter(|d| d.behind) { deco(ctx, d); } + // Text shadows, then glows, under all the text. + for pass in [TextEffect::Shadow, TextEffect::Glow] { + for run in &l.runs { + let ps = para(run.para); + if ps.visible && ps.opacity > 0.001 { + text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity); + } + } + } for run in &l.runs { if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { continue; @@ -1176,6 +1199,49 @@ fn line_bounds(li: &deckcraft_text::LineInfo) -> Option { (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r) } +#[derive(Clone, Copy, PartialEq)] +enum TextEffect { + Shadow, + Glow, +} + +/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`. +fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) { + let Some(fx) = run.effects else { return }; + let k = run.size / run.face.upem.max(1.0); + let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let px = scale_of(m); + let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) { + (TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0), + // Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r + // past the glyph's edge and fades out by about two thirds of r. + (TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68), + _ => return, + }; + let alpha = (c.a as f64 / 255.0) * opacity; + if alpha <= 0.0 { + return; + } + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma))); + ctx.set_paint(color(c.with_alpha(255), 1.0)); + for (gid, x, y) in &run.glyphs { + let path = glyph(&run.face, *gid); + if path.elements().is_empty() { + continue; + } + // The offset is in slide space: a shadow falls the same way whatever the text's rotation. + let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k); + ctx.set_transform(at); + ctx.fill_path(&path); + if grow > 0.0 { + ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } + } + ctx.pop_layer(); +} + /// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`. pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) { draw_glyph_with(ctx, run, gid, at, opacity, None); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index d739e775..172fccbe 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -701,3 +701,52 @@ fn a_text_gradient_starts_over_on_every_line() { assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band)); } } + +/// A text box of big black "IIII" with `set` applied to its run. +fn effect_text(set: impl Fn(&mut deckcraft_model::text::RunProps)) -> Presentation { + let mut sh = Shape { id: ShapeId(740), xfrm: Some(Xfrm::new(100.0, 100.0, 600.0, 300.0)), text_box: true, ..Default::default() }; + let mut t = TextBody::from_text("IIII"); + let props = &mut t.paragraphs[0].runs[0].props; + props.size = Some(100.0); + props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))); + set(props); + sh.text = Some(t); + deck_with(vec![sh]) +} + +#[test] +fn text_glow_and_shadow_are_drawn() { + let count = |img: &Image, f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count(); + let green = |p: &[u8; 4]| p[1] > 150 && p[0] < 120 && p[2] < 120; + let plain = effect_text(|_| {}); + assert!(!needs_filters(&plain, &plain.slides[0])); + let img = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + assert_eq!(count(&img, &green), 0); + // Glow: a green halo hugging the letters, needing the blur filter. + let glow = + effect_text(|p| p.glow = Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 255, 0)), radius: 8.0 })); + assert!(needs_filters(&glow, &glow.slides[0]), "text glow blurs"); + let img = render_slide(&glow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() }); + let halo = count(&img, &green); + assert!(halo > 300, "green halo pixels {halo}"); + // Shadow: grey ink below and right of the letters, where the plain text has none. + let shadow = effect_text(|p| { + p.shadow = Some(deckcraft_model::style::Shadow { + color: ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 }), + blur: 2.0, + dist: 12.0, + dir: 45.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + }) + }); + let img = render_slide(&shadow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() }); + let base = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let grey = |p: &[u8; 4]| p[0] > 60 && p[0] < 230 && p[0] == p[1] && p[1] == p[2]; + assert!(count(&img, &grey) > count(&base, &grey) + 300, "a grey shadow appears"); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 32bfdc42..bb98a588 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -57,6 +57,25 @@ pub struct GlyphRun { /// Alpha of the fill (0–1). pub alpha: f64, pub gradient: Option, + /// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`). + pub effects: Option, +} + +/// A text run's shadow and glow, colours resolved. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RunEffects { + pub shadow: Option, + /// Glow colour (with its alpha) and radius in points. + pub glow: Option<(Rgba, f64)>, +} + +/// An outer shadow: colour (with its alpha), blur radius and offset in points. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TextShadow { + pub color: Rgba, + pub blur: f64, + pub dx: f64, + pub dy: f64, } impl std::fmt::Debug for GlyphRun { @@ -174,6 +193,7 @@ struct Style { outline: Option<(Rgba, f64)>, link: bool, gradient: Option, + effects: Option, /// "Regular", "Bold"… for the slot and fallback faces. style_name: &'static str, /// BCP 47 language (`a:rPr/@lang`), for shaping. @@ -288,6 +308,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> Styl outline, link: r.link.is_some(), gradient, + effects: run_effects(ctx, r, scale), style_name: style_name(bold, italic), lang: r.lang.clone().filter(|l| !l.is_empty()), ea, @@ -494,6 +515,16 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, /// Shape a logical paragraph into cells: bidi levels, per-grapheme faces, items of one level, /// script, face and shaping style, each shaped with the whole paragraph as context. +/// A run's outer shadow and glow (inner shadows aren't drawn on text), at the layout's `scale`. +fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option { + let shadow = r.shadow.as_ref().filter(|s| !s.inner).map(|s| { + let a = s.dir.to_radians(); + TextShadow { color: ctx.color(&s.color, None), blur: s.blur.max(0.0) * scale, dx: a.cos() * s.dist * scale, dy: a.sin() * s.dist * scale } + }); + let glow = r.glow.as_ref().filter(|g| g.radius > 0.0).map(|g| (ctx.color(&g.color, None), g.radius * scale)); + (shadow.is_some() || glow.is_some()).then_some(RunEffects { shadow, glow }) +} + fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], faces: &mut Vec>, rtl: bool) -> Vec { let db = FontDb::global(); let chars: Vec = text.chars().collect(); @@ -1231,6 +1262,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts link: false, alpha: st.alpha, gradient: None, + effects: None, }); } // Group visually consecutive cells by (style, face). @@ -1264,6 +1296,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts link: st.link, alpha: st.alpha, gradient: st.gradient.clone(), + effects: st.effects, }); cur_key = key; } From d7fbbaeab13864f1c0c475865a368808bd7fc8fe Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:54:25 -0400 Subject: [PATCH 17/49] Bullets: a symbol the text font lacks falls back to a font that has it (#284) Circled-number lists (circleNumDbPlain) and character bullets whose glyph isn't in the paragraph's font were drawn as missing-glyph boxes (numbers) or always from Inter (characters). Both now go through the usual font fallback, as PowerPoint draws them. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 19 ++++++++++++++----- crates/text/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index bb98a588..93c52e4e 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -492,9 +492,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, st.baseline = 0.0; // Symbol fonts (Wingdings) aren't available: map common private-use bullets. let ch = map_symbol_bullet(char, props.bullet_font.as_deref()); - if !st.face.covers(ch.chars().next().unwrap_or('•')) { - st.face = db.face("Inter", "Regular"); - } + bullet_face(db, &mut st, &ch); Some((ch, st)) } Some(Bullet::AutoNum { scheme, start_at }) => { @@ -504,8 +502,10 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, } rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0)); rp.underline = None; - let st = make_style(ctx, &rp, scale, opts.prompt_color); - Some((autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1))), st)) + let mut st = make_style(ctx, &rp, scale, opts.prompt_color); + let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1))); + bullet_face(db, &mut st, &label); + Some((label, st)) } _ => None, } @@ -513,6 +513,15 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl } } +/// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows +/// and other symbols missing from the text font), by the usual fallback. +fn bullet_face(db: &FontDb, st: &mut Style, label: &str) { + let chars: Vec = label.chars().collect(); + if !chars.is_empty() && !deckcraft_fonts::covers_cluster(&st.face, &chars) { + st.face = db.cascade(&chars, std::slice::from_ref(&st.face), st.style_name); + } +} + /// Shape a logical paragraph into cells: bidi levels, per-grapheme faces, items of one level, /// script, face and shaping style, each shaped with the whole paragraph as context. /// A run's outer shadow and glow (inner shadows aren't drawn on text), at the layout's `scale`. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index cd78b903..1b9a58c5 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -602,3 +602,25 @@ fn emoji_take_a_colour_face_when_one_is_installed() { assert!(l.runs.iter().filter(|r| r.cells.iter().all(|(_, c)| c.is_ascii())).all(|r| !r.face.is_color()), "the letters keep a text face"); } } + +#[test] +fn a_bullet_the_text_font_lacks_falls_back_to_a_face_that_has_it() { + // circleNumDbPlain numbers ①②…: the text font usually has no circled digits; PowerPoint draws + // them from a fallback font instead of a missing-glyph box. Skipped when no installed or + // bundled face has ①. + let db = FontDb::global(); + if !db.cascade(&['①'], &[], "Regular").covers('①') { + return; + } + let (p, mut sh) = setup("One\nTwo", 600.0, 300.0); + for para in &mut sh.text.as_mut().unwrap().paragraphs { + para.props.bullet = Some(Bullet::AutoNum { scheme: "circleNumDbPlain".into(), start_at: 1 }); + } + let l = lay(&p, &sh, 600.0, 300.0); + let bullets: Vec<_> = l.runs.iter().filter(|r| r.cells.is_empty()).collect(); + assert_eq!(bullets.len(), 2); + for b in bullets { + assert!(b.face.covers('①'), "drawn with {}, which lacks the circled digit", b.face.family); + assert!(b.glyphs.iter().all(|g| g.0 != 0), "no missing-glyph box"); + } +} From 81b6a9df39e97962e5ef32022f242d6ce904e8d4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:56:30 -0400 Subject: [PATCH 18/49] Render: pattern fills draw PowerPoint's preset tiles (#285) Pattern fills were approximated (dots, bars or a grid per family, on an 8-point cell), so pct30 looked like sparse dots and wave like a dot grid. Every one of the 54 DrawingML presets now draws its 8x8 tile, each cell one 96-dpi pixel (0.75 pt), laid out from the slide's origin. The tiles were read from PowerPoint's own export of each preset at two output pixels per cell; the pattern probe deck now matches it to a mean pixel difference of 0.2 (from 62). Co-authored-by: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 1 + crates/render/src/paint.rs | 46 +------- crates/render/src/pattern.rs | 208 +++++++++++++++++++++++++++++++++++ crates/render/src/tests.rs | 26 +++++ 4 files changed, 236 insertions(+), 45 deletions(-) create mode 100644 crates/render/src/pattern.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9a63af75..07e4fce8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -13,6 +13,7 @@ mod images; mod morph_path; mod morph_text; mod paint; +mod pattern; mod placed; mod ramp; mod table; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 18822467..5ed207cd 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -47,55 +47,11 @@ pub fn fill_path( Fill::Pattern(p) => { let fg = rctx.color(&p.fg, ph); let bg = rctx.color(&p.bg, ph); - ctx.set_paint(color(bg, alpha)); - ctx.fill_path(path); - ctx.push_clip_layer(path); - ctx.set_paint(color(fg, alpha)); - pattern(ctx, &p.preset, bounds, m); - ctx.pop_layer(); + crate::pattern::fill(ctx, &p.preset, fg, bg, alpha, path, m); } } } -/// Draw a pattern's foreground marks over `bounds` (8 pt cells). -fn pattern(ctx: &mut RenderContext, preset: &str, b: Rect, m: Affine) { - let k = crate::scale_of(m); - let cell = 6.0 / if k.is_finite() { k.clamp(0.25, 4.0) } else { 1.0 }; - let cell = cell.max(2.0); - let mut p = BezPath::new(); - let (nx, ny) = (((b.width() / cell).ceil() as i64).clamp(0, 2000), ((b.height() / cell).ceil() as i64).clamp(0, 2000)); - let lw = cell * 0.18; - let dense = preset.starts_with("dk") || preset.contains("50") || preset.contains("75") || preset.contains("90"); - for j in 0..=ny { - for i in 0..=nx { - let x = b.x0 + i as f64 * cell; - let y = b.y0 + j as f64 * cell; - let r = match preset { - p if p.contains("Horz") || p == "horz" || p.contains("horzBrick") => Rect::new(x, y, x + cell, y + lw), - p if p.contains("Vert") || p == "vert" => Rect::new(x, y, x + lw, y + cell), - p if p.contains("cross") || p.contains("Grid") || p.contains("grid") => { - p_add(&mut p_rect(x, y, cell, lw), &mut BezPath::new()); - Rect::new(x, y, x + cell, y + lw).union(Rect::new(x, y, x + lw, y + cell)) - } - _ => { - let d = if dense { cell * 0.35 } else { cell * 0.2 }; - Rect::new(x, y, x + d, y + d) - } - }; - p.extend(r.to_path(0.1).iter()); - if preset.contains("cross") || preset.contains("Grid") || preset.contains("grid") { - p.extend(Rect::new(x, y, x + lw, y + cell).to_path(0.1).iter()); - } - } - } - ctx.fill_path(&p); -} - -fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath { - Rect::new(x, y, x + w, y + h).to_path(0.1) -} -fn p_add(_a: &mut BezPath, _b: &mut BezPath) {} - /// `pf` with its duotone colours resolved to plain RGB against the slide's colours and the /// placeholder colour `ph` (theme background pictures recolour with `phClr`), so the decoded-image /// cache can apply and key them without a colour scheme. diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs new file mode 100644 index 00000000..99c6b291 --- /dev/null +++ b/crates/render/src/pattern.rs @@ -0,0 +1,208 @@ +//! Preset pattern fills (`a:pattFill`): an 8×8 two-colour tile, each cell one 96-dpi pixel +//! (0.75 pt) of the slide, as PowerPoint draws them. +//! +//! The tiles were read from PowerPoint's own slide export of every preset (black on white, at +//! exactly two output pixels per pattern pixel), so the marks, their density and their phase +//! match it. Bit 7 of each row is the leftmost cell; a set bit is the foreground colour. + +use std::sync::Arc; + +use deckcraft_color::Rgba; +use kurbo::{Affine, BezPath}; +use vello_cpu::{Pixmap, RenderContext, peniko}; + +/// Side of one pattern cell, in points. +pub const CELL: f64 = 0.75; + +const TILES: &[(&str, [u8; 8])] = &[ + ("pct5", [0x80, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00]), + ("pct10", [0x80, 0x00, 0x08, 0x00, 0x80, 0x00, 0x08, 0x00]), + ("pct20", [0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00]), + ("pct25", [0x88, 0x22, 0x88, 0x22, 0x88, 0x22, 0x88, 0x22]), + ("pct30", [0xAA, 0x44, 0xAA, 0x11, 0xAA, 0x44, 0xAA, 0x11]), + ("pct40", [0xAA, 0x55, 0xAA, 0x51, 0xAA, 0x55, 0xAA, 0x15]), + ("pct50", [0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]), + ("pct60", [0xEE, 0x55, 0xBB, 0x55, 0xEE, 0x55, 0xBB, 0x55]), + ("pct70", [0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD]), + ("pct75", [0x77, 0xFF, 0xDD, 0xFF, 0x77, 0xFF, 0xDD, 0xFF]), + ("pct80", [0xEF, 0xFF, 0xFE, 0xFF, 0xEF, 0xFF, 0xFE, 0xFF]), + ("pct90", [0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, 0xFF, 0x7F]), + ("horz", [0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + ("vert", [0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]), + ("ltHorz", [0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00]), + ("ltVert", [0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88]), + ("dkHorz", [0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00]), + ("dkVert", [0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC]), + ("narHorz", [0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00]), + ("narVert", [0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55]), + ("dashHorz", [0xF0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00]), + ("dashVert", [0x80, 0x80, 0x80, 0x80, 0x08, 0x08, 0x08, 0x08]), + ("cross", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]), + ("dnDiag", [0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01]), + ("upDiag", [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80]), + ("ltDnDiag", [0x88, 0x44, 0x22, 0x11, 0x88, 0x44, 0x22, 0x11]), + ("ltUpDiag", [0x11, 0x22, 0x44, 0x88, 0x11, 0x22, 0x44, 0x88]), + ("dkDnDiag", [0xCC, 0x66, 0x33, 0x99, 0xCC, 0x66, 0x33, 0x99]), + ("dkUpDiag", [0x33, 0x66, 0xCC, 0x99, 0x33, 0x66, 0xCC, 0x99]), + ("wdDnDiag", [0xC1, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x83]), + ("wdUpDiag", [0x83, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xC1]), + ("dashDnDiag", [0x00, 0x00, 0x88, 0x44, 0x22, 0x11, 0x00, 0x00]), + ("dashUpDiag", [0x00, 0x00, 0x11, 0x22, 0x44, 0x88, 0x00, 0x00]), + ("diagCross", [0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81]), + ("smCheck", [0x99, 0x66, 0x66, 0x99, 0x99, 0x66, 0x66, 0x99]), + ("lgCheck", [0xF0, 0xF0, 0xF0, 0xF0, 0x0F, 0x0F, 0x0F, 0x0F]), + ("smGrid", [0xFF, 0x88, 0x88, 0x88, 0xFF, 0x88, 0x88, 0x88]), + ("lgGrid", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]), + ("dotGrid", [0xAA, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00]), + ("smConfetti", [0x80, 0x08, 0x40, 0x02, 0x10, 0x01, 0x20, 0x04]), + ("lgConfetti", [0xB1, 0x30, 0x03, 0x1B, 0xD8, 0xC0, 0x0C, 0x8D]), + ("horzBrick", [0xFF, 0x80, 0x80, 0x80, 0xFF, 0x08, 0x08, 0x08]), + ("diagBrick", [0x01, 0x02, 0x04, 0x08, 0x18, 0x24, 0x42, 0x81]), + ("solidDmnd", [0x10, 0x38, 0x7C, 0xFE, 0x7C, 0x38, 0x10, 0x00]), + ("openDmnd", [0x82, 0x44, 0x28, 0x10, 0x28, 0x44, 0x82, 0x01]), + ("dotDmnd", [0x80, 0x00, 0x22, 0x00, 0x08, 0x00, 0x22, 0x00]), + ("plaid", [0xAA, 0x55, 0xAA, 0x55, 0xF0, 0xF0, 0xF0, 0xF0]), + ("sphere", [0x77, 0x89, 0x8F, 0x8F, 0x77, 0x98, 0xF8, 0xF8]), + ("weave", [0x88, 0x54, 0x22, 0x45, 0x88, 0x14, 0x22, 0x51]), + ("divot", [0x00, 0x10, 0x08, 0x10, 0x00, 0x80, 0x01, 0x80]), + ("shingle", [0x03, 0x84, 0x48, 0x30, 0x0C, 0x02, 0x01, 0x01]), + ("wave", [0x00, 0x18, 0x25, 0xC0, 0x00, 0x18, 0x25, 0xC0]), + ("trellis", [0xFF, 0x66, 0xFF, 0x99, 0xFF, 0x66, 0xFF, 0x99]), + ("zigZag", [0x81, 0x42, 0x24, 0x18, 0x81, 0x42, 0x24, 0x18]), +]; + +/// The 8×8 tile of preset `name` (`pct50` for an unknown name). +pub fn tile(name: &str) -> [u8; 8] { + TILES + .iter() + .find(|(n, _)| *n == name) + .or_else(|| TILES.iter().find(|(n, _)| *n == "pct50")) + .map(|(_, t)| *t) + .unwrap_or([0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]) +} + +/// Is cell (`x`, `y`) of the tile the foreground? +pub fn is_fg(tile: &[u8; 8], x: usize, y: usize) -> bool { + tile.get(y % 8).is_some_and(|row| row & (0x80 >> (x % 8)) != 0) +} + +fn premul(c: Rgba, alpha: f64) -> vello_cpu::color::PremulRgba8 { + let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0); + let m = |v: u8| (v as f64 * a / 255.0).round().clamp(0.0, 255.0) as u8; + vello_cpu::color::PremulRgba8 { r: m(c.r), g: m(c.g), b: m(c.b), a: a as u8 } +} + +/// Fill `path` (in the space `m` maps to the output) with preset `name` in `fg` on `bg`, faded by +/// `alpha`. +pub fn fill(ctx: &mut RenderContext, name: &str, fg: Rgba, bg: Rgba, alpha: f64, path: &BezPath, m: Affine) { + let det = m.determinant(); + if !det.is_finite() || det.abs() <= 1e-12 { + return; + } + let t = tile(name); + let (f, b) = (premul(fg, alpha), premul(bg, alpha)); + let data: Vec = (0..64).map(|i| if is_fg(&t, i % 8, i / 8) { f } else { b }).collect(); + let pm = Arc::new(Pixmap::from_parts(data, 8, 8)); + ctx.set_transform(m); + ctx.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm), + sampler: peniko::ImageSampler { + x_extend: peniko::Extend::Repeat, + y_extend: peniko::Extend::Repeat, + quality: peniko::ImageQuality::Low, + alpha: 1.0, + }, + }); + // Cells are laid out on the output from its origin (the slide's top-left corner), so + // neighbouring shapes' patterns line up, as in PowerPoint. + let k = crate::scale_of(m); + let k = if k.is_finite() && k > 0.0 { k } else { 1.0 }; + ctx.set_paint_transform(m.inverse() * Affine::scale(CELL * k)); + ctx.fill_path(path); + ctx.reset_paint_transform(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_drawingml_preset_has_a_tile() { + // ECMA-376 ST_PresetPatternVal. + let all = [ + "pct5", + "pct10", + "pct20", + "pct25", + "pct30", + "pct40", + "pct50", + "pct60", + "pct70", + "pct75", + "pct80", + "pct90", + "horz", + "vert", + "ltHorz", + "ltVert", + "dkHorz", + "dkVert", + "narHorz", + "narVert", + "dashHorz", + "dashVert", + "cross", + "dnDiag", + "upDiag", + "ltDnDiag", + "ltUpDiag", + "dkDnDiag", + "dkUpDiag", + "wdDnDiag", + "wdUpDiag", + "dashDnDiag", + "dashUpDiag", + "diagCross", + "smCheck", + "lgCheck", + "smGrid", + "lgGrid", + "dotGrid", + "smConfetti", + "lgConfetti", + "horzBrick", + "diagBrick", + "solidDmnd", + "openDmnd", + "dotDmnd", + "plaid", + "sphere", + "weave", + "divot", + "shingle", + "wave", + "trellis", + "zigZag", + ]; + assert_eq!(all.len(), 54); + for n in all { + assert!(TILES.iter().any(|(t, _)| *t == n), "{n}"); + } + assert_eq!(TILES.len(), 54); + } + + #[test] + fn percent_presets_get_denser() { + // The pctN tiles set more cells as N grows (PowerPoint's own: pct5 sets 2 of 64, pct50 + // half, pct90 all but 2). + let share = |n: &str| { + let t = tile(n); + (0..64).filter(|i| is_fg(&t, i % 8, i / 8)).count() + }; + let names = ["pct5", "pct10", "pct20", "pct25", "pct30", "pct40", "pct50", "pct60", "pct70", "pct75", "pct80", "pct90"]; + assert!(names.windows(2).all(|w| share(w[0]) <= share(w[1])), "{:?}", names.map(share)); + assert_eq!((share("pct5"), share("pct50"), share("pct90")), (2, 32, 62)); + assert_eq!(tile("nonsense"), tile("pct50")); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 172fccbe..f7915322 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -750,3 +750,29 @@ fn text_glow_and_shadow_are_drawn() { let grey = |p: &[u8; 4]| p[0] > 60 && p[0] < 230 && p[0] == p[1] && p[1] == p[2]; assert!(count(&img, &grey) > count(&base, &grey) + 300, "a grey shadow appears"); } + +#[test] +fn pattern_fills_draw_powerpoints_tiles() { + // dkVert at one output pixel per pattern cell (0.75 pt): columns 11001100… across, as + // PowerPoint's export draws it; pct50 is a checkerboard. + use deckcraft_model::style::{Fill, PatternFill}; + let rgb = |v| ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)); + let shape = |id, x: f64, preset: &str| Shape { + id: ShapeId(id), + xfrm: Some(Xfrm::new(x, 0.0, 60.0, 60.0)), + fill: Some(Fill::Pattern(PatternFill { preset: preset.into(), fg: rgb(0), bg: rgb(255) })), + line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), + ..Default::default() + }; + let p = deck_with(vec![shape(750, 0.0, "dkVert"), shape(751, 120.0, "pct50")]); + let img = render_slide(&p, 0, &RenderOpts { scale: 4.0 / 3.0, ..Default::default() }); + let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 128; + let row: Vec = (8..16).map(|x| dark(x, 20)).collect(); + assert_eq!(row, [true, true, false, false, true, true, false, false], "dkVert columns"); + assert!((8..16).all(|y| dark(8, y) == dark(8, 20)), "dkVert is vertical"); + // pct50 starts at slide x = 120 pt = 160 px. + for (x, y) in [(168, 8), (169, 9), (170, 12)] { + assert_ne!(dark(x, y), dark(x + 1, y), "pct50 alternates across at {x},{y}"); + assert_eq!(dark(x, y), dark(x + 1, y + 1), "pct50 repeats diagonally at {x},{y}"); + } +} From d0a3d924bf82af4d4e52d0021a54ebb8a02bcf6e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:58:10 -0400 Subject: [PATCH 19/49] Render: compound outlines (double, thick-thin, thin-thick, triple) (#287) a:ln/@cmpd was read but every outline was drawn as one band. Each band is now stroked along the path moved to its place across the width, in the shares PowerPoint uses (measured on its export of 24 pt lines: double 1/3-1/3-1/3, thin-thick 1/5-1/5-3/5, triple 1/6-1/6-1/3-1/6-1/6), the first band on the left of travel (the outside of a shape). Line corners meet at their miter points; curves are offset with kurbo. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/render/src/compound.rs | 242 ++++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 15 +++ crates/render/src/tests.rs | 44 +++++++ 3 files changed, 301 insertions(+) create mode 100644 crates/render/src/compound.rs diff --git a/crates/render/src/compound.rs b/crates/render/src/compound.rs new file mode 100644 index 00000000..aa1c21e1 --- /dev/null +++ b/crates/render/src/compound.rs @@ -0,0 +1,242 @@ +//! Compound outlines (`a:ln/@cmpd`): double, thick-thin, thin-thick and triple lines. +//! +//! PowerPoint splits the line's width into bands across the path, measured from its left side +//! (looking along the path) to its right, as these shares of the width (read from PowerPoint's +//! export of 24 pt lines of each kind): +//! - double: 1/3 line, 1/3 gap, 1/3 line; +//! - thin-thick: 1/5 line, 1/5 gap, 3/5 line; thick-thin the mirror; +//! - triple: 1/6 line, 1/6 gap, 1/3 line, 1/6 gap, 1/6 line. +//! +//! On a shape drawn clockwise (all presets) the left side is the outside. + +use deckcraft_model::style::Compound; +use kurbo::{BezPath, CubicBez, ParamCurve, PathEl, PathSeg, Point, Vec2}; + +/// The bands of `c` as (from, to) shares of the width, left side first. +pub fn bands(c: Compound) -> &'static [(f64, f64)] { + match c { + Compound::Single => &[(0.0, 1.0)], + Compound::Double => &[(0.0, 1.0 / 3.0), (2.0 / 3.0, 1.0)], + Compound::ThinThick => &[(0.0, 0.2), (0.4, 1.0)], + Compound::ThickThin => &[(0.0, 0.6), (0.8, 1.0)], + Compound::Triple => &[(0.0, 1.0 / 6.0), (2.0 / 6.0, 4.0 / 6.0), (5.0 / 6.0, 1.0)], + } +} + +/// Unit normal pointing to the right of travel (y down), or None for a zero-length tangent. +fn right(t: Vec2) -> Option { + let l = t.hypot(); + (l > 1e-12 && l.is_finite()).then(|| Vec2::new(-t.y, t.x) / l) +} + +/// `path` moved `d` to the right of travel (negative: to the left). Line corners meet at their +/// miter point (bevelled when sharper than about 11°); curves are offset by kurbo; other joins are +/// bridged by a straight line. +pub fn offset(path: &BezPath, d: f64, tolerance: f64) -> BezPath { + let mut out = BezPath::new(); + if d.abs() < 1e-9 { + return path.clone(); + } + // Each subpath as segments, offset one by one. + let mut segs: Vec = Vec::new(); + let mut closed = false; + let flush = |segs: &mut Vec, closed: bool, out: &mut BezPath| { + let pieces: Vec<(PathSeg, BezPath)> = segs.iter().filter_map(|s| offset_seg(*s, d, tolerance).map(|p| (*s, p))).collect(); + let begin = out.elements().len(); + for (i, (seg, piece)) in pieces.iter().enumerate() { + let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() else { continue }; + match i.checked_sub(1).and_then(|j| pieces.get(j)) { + None => out.move_to(p), + Some((prev, _)) => join(out, *prev, *seg, p, d), + } + for e in piece.elements().iter().skip(1) { + out.push(*e); + } + } + if closed && let (Some((last, _)), Some((first, piece))) = (pieces.last(), pieces.first()) { + if let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() { + // Lines meeting at the start: both ends move to their miter point. + match miter(*last, *first, d) { + Some(m) if is_line(*last) && is_line(*first) => { + set_last(out, m); + if let Some(PathEl::MoveTo(_)) = out.elements().get(begin) { + out.elements_mut()[begin] = PathEl::MoveTo(m); + } + } + _ => out.line_to(p), + } + } + out.close_path(); + } + segs.clear(); + }; + let mut start = Point::ZERO; + let mut cur = Point::ZERO; + for el in path.elements() { + match *el { + PathEl::MoveTo(p) => { + flush(&mut segs, closed, &mut out); + closed = false; + start = p; + cur = p; + } + PathEl::LineTo(p) => { + segs.push(PathSeg::Line(kurbo::Line::new(cur, p))); + cur = p; + } + PathEl::QuadTo(a, b) => { + segs.push(PathSeg::Quad(kurbo::QuadBez::new(cur, a, b))); + cur = b; + } + PathEl::CurveTo(a, b, c) => { + segs.push(PathSeg::Cubic(CubicBez::new(cur, a, b, c))); + cur = c; + } + PathEl::ClosePath => { + if (cur - start).hypot() > 1e-9 { + segs.push(PathSeg::Line(kurbo::Line::new(cur, start))); + } + closed = true; + flush(&mut segs, closed, &mut out); + closed = false; + cur = start; + } + } + } + flush(&mut segs, closed, &mut out); + out +} + +fn offset_seg(s: PathSeg, d: f64, tolerance: f64) -> Option { + // Far-off or non-finite geometry (hostile files) is left out rather than offset. + let sane = |p: Point| p.is_finite() && p.x.abs() < 1e7 && p.y.abs() < 1e7; + let ok = match s { + PathSeg::Line(l) => sane(l.p0) && sane(l.p1), + PathSeg::Quad(q) => sane(q.p0) && sane(q.p1) && sane(q.p2), + PathSeg::Cubic(c) => sane(c.p0) && sane(c.p1) && sane(c.p2) && sane(c.p3), + }; + if !ok || !d.is_finite() { + return None; + } + match s { + PathSeg::Line(l) => { + let n = right(l.p1 - l.p0)?; + let mut p = BezPath::new(); + p.move_to(l.p0 + n * d); + p.line_to(l.p1 + n * d); + Some(p) + } + PathSeg::Quad(q) => offset_seg(PathSeg::Cubic(q.raise()), d, tolerance), + PathSeg::Cubic(c) => { + // Nearly straight cubics (collinear control points) are offset as lines. + let chord = c.p3 - c.p0; + let flat = |p: Point| chord.cross(p - c.p0).abs() <= 1e-9 * chord.hypot2().max(1e-18); + if flat(c.p1) && flat(c.p2) { + return offset_seg(PathSeg::Line(kurbo::Line::new(c.p0, c.p3)), d, tolerance); + } + let mut p = BezPath::new(); + kurbo::offset::offset_cubic(c, d, tolerance, &mut p); + Some(p) + } + } +} + +/// The segment's tangent at its start and at its end (the first non-zero control vector). +fn tangents(s: PathSeg) -> (Vec2, Vec2) { + let first = |v: &[Vec2]| v.iter().copied().find(|v| v.hypot2() > 1e-18).unwrap_or(Vec2::ZERO); + match s { + PathSeg::Line(l) => (l.p1 - l.p0, l.p1 - l.p0), + PathSeg::Quad(q) => (first(&[q.p1 - q.p0, q.p2 - q.p0]), first(&[q.p2 - q.p1, q.p2 - q.p0])), + PathSeg::Cubic(c) => (first(&[c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0]), first(&[c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0])), + } +} + +fn is_line(s: PathSeg) -> bool { + matches!(s, PathSeg::Line(_)) +} + +/// Move the path's last point to `p`. +fn set_last(out: &mut BezPath, p: Point) { + if let Some(e) = out.elements_mut().last_mut() { + match e { + PathEl::MoveTo(q) | PathEl::LineTo(q) => *q = p, + PathEl::QuadTo(_, q) => *q = p, + PathEl::CurveTo(_, _, q) => *q = p, + PathEl::ClosePath => {} + } + } +} + +/// Where the offsets of `prev` and `next` meet at their shared corner (their miter point), or +/// None for a corner sharper than about 11 degrees or a degenerate tangent. +fn miter(prev: PathSeg, next: PathSeg, d: f64) -> Option { + let (_, t0) = tangents(prev); + let (t1, _) = tangents(next); + let (n0, n1) = (right(t0)?, right(t1)?); + let denom = 1.0 + n0.dot(n1); + (denom > 0.02).then(|| prev.end() + (n0 + n1) * (d / denom)).filter(|m| m.is_finite()) +} + +/// Join the offset of `prev` (ending at `out`'s current point) to the offset of `next`, which +/// starts at `p`: two lines meet at their miter point (both ends moved there, so an inner corner +/// doesn't loop); anything else is bridged straight. +fn join(out: &mut BezPath, prev: PathSeg, next: PathSeg, p: Point, d: f64) { + match miter(prev, next, d) { + Some(m) if is_line(prev) && is_line(next) => set_last(out, m), + Some(m) => { + out.line_to(m); + out.line_to(p); + } + None => out.line_to(p), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Shape; + + #[test] + fn bands_split_the_width_like_powerpoint() { + for c in [Compound::Single, Compound::Double, Compound::ThinThick, Compound::ThickThin, Compound::Triple] { + let b = bands(c); + assert_eq!(b.first().map(|x| x.0), Some(0.0)); + assert_eq!(b.last().map(|x| x.1), Some(1.0)); + assert!(b.windows(2).all(|w| w[0].1 < w[1].0), "{c:?}: bands in order with gaps"); + } + // Thin-thick: the thin line on the left, thick-thin its mirror. + let (tt, th) = (bands(Compound::ThinThick), bands(Compound::ThickThin)); + assert!((tt[0].1 - tt[0].0) < (tt[1].1 - tt[1].0)); + assert!((th[0].1 - th[0].0) > (th[1].1 - th[1].0)); + } + + #[test] + fn offsetting_a_rectangle_grows_or_shrinks_it() { + // Clockwise in y-down coordinates: the left of travel is the outside. + let r = kurbo::Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1); + let out = offset(&r, -5.0, 0.01).bounding_box(); + assert!( + (out.x0 + 5.0).abs() < 1e-6 && (out.y0 + 5.0).abs() < 1e-6 && (out.x1 - 105.0).abs() < 1e-6 && (out.y1 - 55.0).abs() < 1e-6, + "{out:?}" + ); + let inn = offset(&r, 5.0, 0.01).bounding_box(); + assert!((inn.x0 - 5.0).abs() < 1e-6 && (inn.y1 - 45.0).abs() < 1e-6, "{inn:?}"); + // Corners meet at their miter points: the area is the offset rectangle's. + assert!((offset(&r, -5.0, 0.01).area().abs() - 110.0 * 60.0).abs() < 1e-6); + } + + #[test] + fn offsetting_a_circle_changes_its_radius() { + let c = kurbo::Circle::new((50.0, 50.0), 20.0).to_path(0.01); + let b = offset(&c, 4.0, 0.01).bounding_box(); + let w = b.width() / 2.0; + assert!((w - 16.0).abs() < 0.2 || (w - 24.0).abs() < 0.2, "radius {w}"); + // A hostile path never panics and yields finite output. + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((0.0, 0.0)); + p.curve_to((f64::MAX, 0.0), (0.0, f64::MIN_POSITIVE), (1.0, 1.0)); + p.close_path(); + let _ = offset(&p, 3.0, 0.01); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 07e4fce8..4376b46c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -9,6 +9,8 @@ mod chart; mod colr; + +mod compound; mod images; mod morph_path; mod morph_text; @@ -941,6 +943,19 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, pa } _ => ctx.set_paint(color(Rgba::BLACK, alpha)), } + // Compound lines: each band stroked along the path moved to its place across the width. + let compound = line.compound.unwrap_or_default(); + if compound != deckcraft_model::style::Compound::Single && lw * scale_of(m) >= 1.5 { + let tolerance = 0.1 / scale_of(m).max(1e-6); + for &(a, b) in compound::bands(compound) { + let d = (-0.5 + (a + b) / 2.0) * lw; + let mut band = sk.clone(); + band.width = (b - a) * lw; + ctx.set_stroke(band); + ctx.stroke_path(&compound::offset(path, d, tolerance)); + } + return; + } ctx.set_stroke(sk); ctx.stroke_path(path); } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index f7915322..dc057fb9 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -776,3 +776,47 @@ fn pattern_fills_draw_powerpoints_tiles() { assert_eq!(dark(x, y), dark(x + 1, y + 1), "pct50 repeats diagonally at {x},{y}"); } } + +#[test] +fn compound_lines_draw_their_bands() { + // A 24 pt outline on a rectangle, as PowerPoint's export draws each kind (top edge, outside + // first): double 8/8/8, thin-thick 4.8/4.8/14.4, triple 4/4/8/4/4. + use deckcraft_model::style::{Compound, Fill, Line}; + let runs = |c: Compound| { + let sh = Shape { + id: ShapeId(760), + xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)), + fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))), + line: Some(Line { + fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))), + width: Some(24.0), + compound: Some(c), + ..Default::default() + }), + ..Default::default() + }; + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 2.0, ..Default::default() }); + // Down the middle of the top edge (y 100 pt), from 80 pt to 120 pt: lengths of dark and + // light runs in points. + let mut out: Vec<(bool, f64)> = vec![]; + for y in 160..240 { + let dark = img.pixel(500, y)[0] < 128; + match out.last_mut() { + Some((d, n)) if *d == dark => *n += 0.5, + _ => out.push((dark, 0.5)), + } + } + out.into_iter().filter(|(d, _)| *d).map(|(_, n)| n).collect::>() + }; + let close = |got: Vec, want: &[f64]| got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1.0); + assert!(close(runs(Compound::Single), &[24.0])); + let d = runs(Compound::Double); + assert!(close(d.clone(), &[8.0, 8.0]), "{d:?}"); + let t = runs(Compound::ThinThick); + assert!(close(t.clone(), &[4.8, 14.4]), "{t:?}"); + let k = runs(Compound::ThickThin); + assert!(close(k.clone(), &[14.4, 4.8]), "{k:?}"); + let tri = runs(Compound::Triple); + assert!(close(tri.clone(), &[4.0, 8.0, 4.0]), "{tri:?}"); +} From b3e33928e2ccd9bb31725cfa9fbac13e809aa7fc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:59:32 -0400 Subject: [PATCH 20/49] Render: rectangle and outline path gradients (#288) Path gradients that follow a rectangle (path="rect") or the shape's own outline (path="shape") were drawn as circles. They're now rasterised into a small image of the bounds: rect takes the larger of the horizontal and vertical shares of the distance from the focus to the edge (X-shaped contours), shape the share of the way from the focus to the outline along the same ray (an ellipse gives ellipses, a triangle triangles), as measured on PowerPoint's export. The two path probe decks now match it to a mean pixel difference of 1.7 and 0.6 (from 15.6 and 22). Co-authored-by: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 2 + crates/render/src/paint.rs | 35 ++++++ crates/render/src/pathgrad.rs | 220 ++++++++++++++++++++++++++++++++++ crates/render/src/tests.rs | 26 ++++ 4 files changed, 283 insertions(+) create mode 100644 crates/render/src/pathgrad.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4376b46c..52d90f3b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -16,6 +16,8 @@ mod morph_path; mod morph_text; mod paint; mod pattern; + +mod pathgrad; mod placed; mod ramp; mod table; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 5ed207cd..6dd079fb 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -37,6 +37,41 @@ pub fn fill_path( } let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect(); ctx.set_paint(gradient(&g.shape, stops, bounds, alpha)); + + let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect(); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Rectangle and outline paths: rasterised (see `pathgrad`). + if let GradientShape::Path { path: kind, focus } = &g.shape + && matches!(kind.as_str(), "rect" | "shape") + { + crate::pathgrad::fill(ctx, kind, *focus, &crate::ramp::expand(&stops), bounds, path, m, alpha); + return; + } + // PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`. + let stops: Vec = + crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect(); + let grad = match &g.shape { + GradientShape::Linear { angle, .. } => { + // Angle measured clockwise from left→right; span the bounds along that direction. + let a = angle.to_radians(); + let (dx, dy) = (a.cos(), a.sin()); + let c = bounds.center(); + let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0; + peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half)) + .with_stops(stops.as_slice()) + } + GradientShape::Path { focus, .. } => { + let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0; + let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0; + // Reach the farthest corner, so a gradient from a corner covers the whole box. + let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)] + .iter() + .map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt()) + .fold(1e-6, f64::max); + peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice()) + } + }; + ctx.set_paint(grad); ctx.fill_path(path); } Fill::Picture(pf) => { diff --git a/crates/render/src/pathgrad.rs b/crates/render/src/pathgrad.rs new file mode 100644 index 00000000..03855560 --- /dev/null +++ b/crates/render/src/pathgrad.rs @@ -0,0 +1,220 @@ +//! Path gradients (`a:path`) that follow a rectangle or the shape's own outline, which no vello +//! gradient draws: they're rasterised into a small image of the bounds and painted with it. +//! +//! Measured on PowerPoint's export (black to grey to white from the centre): +//! - `rect`: the colour position is the larger of the horizontal and vertical distances from the +//! focus, each as a share of the distance to that side's edge (X-shaped contours); +//! - `shape`: contours are the outline scaled about the focus (an ellipse gives ellipses, a +//! triangle triangles): the share of the way from the focus to the outline along the same ray. +//! +//! `circle` stays a radial vello gradient (see `paint::gradient`). + +use std::sync::Arc; + +use deckcraft_color::Rgba; +use kurbo::{Affine, BezPath, PathEl, Point, Rect}; +use vello_cpu::{Pixmap, RenderContext, peniko}; + +/// Longest side of the rasterised gradient, in pixels (bilinear sampling smooths the rest). +const MAX_SIDE: f64 = 384.0; + +/// Colour position (0 at the focus, 1 at the edge) of `p` for path `kind` in `bounds` with the +/// focus rect `focus` (fractions inset from left, top, right, bottom), `outline` being the +/// shape's flattened polygon(s) for `shape`. +pub fn position(kind: &str, bounds: Rect, focus: [f64; 4], outline: &[Vec], p: Point) -> f64 { + let f = Rect::new( + bounds.x0 + bounds.width() * focus[0].clamp(0.0, 1.0), + bounds.y0 + bounds.height() * focus[1].clamp(0.0, 1.0), + bounds.x1 - bounds.width() * focus[2].clamp(0.0, 1.0), + bounds.y1 - bounds.height() * focus[3].clamp(0.0, 1.0), + ); + let f = if f.x0 <= f.x1 && f.y0 <= f.y1 { f } else { Rect::from_center_size(f.center(), (0.0, 0.0)) }; + match kind { + "shape" if !outline.is_empty() => shape_position(outline, f.center(), p), + _ => { + // Distance outside the focus rect along each axis, over the room to that side's edge. + let axis = |v: f64, lo: f64, hi: f64, edge_lo: f64, edge_hi: f64| { + if v < lo { + (lo - v) / (lo - edge_lo).max(1e-9) + } else if v > hi { + (v - hi) / (edge_hi - hi).max(1e-9) + } else { + 0.0 + } + }; + axis(p.x, f.x0, f.x1, bounds.x0, bounds.x1).max(axis(p.y, f.y0, f.y1, bounds.y0, bounds.y1)).clamp(0.0, 1.0) + } + } +} + +/// The share of the way from `c` to the outline along the ray through `p` (1 when the ray meets +/// no edge). +fn shape_position(outline: &[Vec], c: Point, p: Point) -> f64 { + let d = p - c; + let len = d.hypot(); + if len < 1e-9 { + return 0.0; + } + // Nearest crossing of the ray c + s·d (s > 0) with an edge. + let mut best = f64::INFINITY; + for poly in outline { + for w in poly.windows(2) { + let (a, b) = (w[0], w[1]); + let e = b - a; + let den = d.cross(e); + if den.abs() < 1e-12 { + continue; + } + let ac = a - c; + let s = ac.cross(e) / den; + let u = ac.cross(d) / den; + if s > 1e-9 && (-1e-9..=1.0 + 1e-9).contains(&u) { + best = best.min(s); + } + } + } + if best.is_finite() { (1.0 / best).clamp(0.0, 1.0) } else { 1.0 } +} + +/// The outline flattened into closed polygons. +pub fn polygons(path: &BezPath) -> Vec> { + let mut out: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + kurbo::flatten(path.iter(), 0.25, |el| match el { + PathEl::MoveTo(p) => { + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + cur = vec![p]; + } + PathEl::LineTo(p) => cur.push(p), + PathEl::ClosePath => { + if let Some(f) = cur.first().copied() { + cur.push(f); + } + } + _ => {} + }); + if cur.len() > 1 { + out.push(cur); + } + for poly in &mut out { + if let (Some(f), Some(l)) = (poly.first().copied(), poly.last().copied()) + && (f - l).hypot() > 1e-9 + { + poly.push(f); + } + } + out.retain(|p| p.len() > 2 && p.len() < 100_000); + out +} + +/// A 256-entry colour ramp for the sorted, already expanded `stops` (linear in sRGB between them). +pub fn ramp(stops: &[(f64, Rgba)]) -> Vec { + (0..256) + .map(|i| { + let t = i as f64 / 255.0; + let after = stops.iter().position(|s| s.0 >= t); + match after { + Some(0) | None => stops.get(after.unwrap_or(stops.len().saturating_sub(1))).map(|s| s.1).unwrap_or(Rgba::BLACK), + Some(j) => { + let (Some(a), Some(b)) = (stops.get(j - 1), stops.get(j)) else { return Rgba::BLACK }; + let k = if b.0 - a.0 > 1e-12 { (t - a.0) / (b.0 - a.0) } else { 1.0 }; + a.1.lerp(b.1, k) + } + } + }) + .collect() +} + +/// Fill `path` with a `rect` or `shape` path gradient of `stops` (sorted and expanded, see +/// `ramp::expand`) over `bounds`, faded by `alpha`; `m` maps the path to the output. +#[allow(clippy::too_many_arguments)] +pub fn fill(ctx: &mut RenderContext, kind: &str, focus: [f64; 4], stops: &[(f64, Rgba)], bounds: Rect, path: &BezPath, m: Affine, alpha: f64) { + if bounds.width() <= 0.0 || bounds.height() <= 0.0 || !bounds.is_finite() || stops.is_empty() { + return; + } + let k = crate::scale_of(m); + let k = if k.is_finite() && k > 0.0 { k } else { 1.0 }; + let longest = bounds.width().max(bounds.height()) * k; + let s = (MAX_SIDE / longest).min(1.0) * k; + let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 4096), ((bounds.height() * s).ceil() as u32).clamp(1, 4096)); + let outline = if kind == "shape" { polygons(path) } else { Vec::new() }; + let lut = ramp(stops); + let a = alpha.clamp(0.0, 1.0); + let mut data = Vec::with_capacity((pw * ph) as usize); + for j in 0..ph { + for i in 0..pw { + let p = Point::new(bounds.x0 + (i as f64 + 0.5) / pw as f64 * bounds.width(), bounds.y0 + (j as f64 + 0.5) / ph as f64 * bounds.height()); + let t = position(kind, bounds, focus, &outline, p); + let c = lut.get((t * 255.0).round().clamp(0.0, 255.0) as usize).copied().unwrap_or(Rgba::BLACK); + let ca = (c.a as f64 * a).round().clamp(0.0, 255.0); + let mul = |v: u8| (v as f64 * ca / 255.0).round().clamp(0.0, 255.0) as u8; + data.push(vello_cpu::color::PremulRgba8 { r: mul(c.r), g: mul(c.g), b: mul(c.b), a: ca as u8 }); + } + } + let pm = Arc::new(Pixmap::from_parts(data, pw as u16, ph as u16)); + ctx.set_transform(m); + ctx.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm), + sampler: peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() }, + }); + ctx.set_paint_transform( + Affine::translate(bounds.origin().to_vec2()) * Affine::scale_non_uniform(bounds.width() / pw as f64, bounds.height() / ph as f64), + ); + ctx.fill_path(path); + ctx.reset_paint_transform(); +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Shape; + + const CENTRE: [f64; 4] = [0.5, 0.5, 0.5, 0.5]; + + #[test] + fn rect_paths_take_the_larger_axis_share() { + // PowerPoint, 280x200 box from the centre: 20 px across is 1/7, 20 px down is 1/5. + let b = Rect::new(0.0, 0.0, 280.0, 200.0); + let at = |x: f64, y: f64| position("rect", b, CENTRE, &[], Point::new(x, y)); + assert!((at(160.0, 100.0) - 20.0 / 140.0).abs() < 1e-9); + assert!((at(140.0, 120.0) - 20.0 / 100.0).abs() < 1e-9); + assert!((at(168.0, 120.0) - 0.2).abs() < 1e-9, "the diagonal takes the larger share"); + assert_eq!(at(0.0, 0.0), 1.0); + // From a corner (fillToRect 0,0,100%,100%): the far corner is the end. + let corner = [0.0, 0.0, 1.0, 1.0]; + assert!((position("rect", b, corner, &[], Point::new(140.0, 50.0)) - 0.5).abs() < 1e-9); + } + + #[test] + fn shape_paths_follow_the_outline() { + let b = Rect::new(0.0, 0.0, 280.0, 200.0); + let ellipse = polygons(&kurbo::Ellipse::from_rect(b).to_path(0.01)); + let at = |x: f64, y: f64| position("shape", b, CENTRE, &ellipse, Point::new(x, y)); + assert!((at(160.0, 100.0) - 20.0 / 140.0).abs() < 0.01); + assert!((at(140.0, 120.0) - 0.2).abs() < 0.01); + // Half way to the outline along any ray is 0.5. + let (dx, dy) = (140.0 * 0.6, 100.0 * 0.8); + assert!((at(140.0 + dx * 0.5, 100.0 + dy * 0.5) - 0.5).abs() < 0.01); + // A triangle: half way from its centre to its apex is 0.5. + let mut tri = BezPath::new(); + tri.move_to((140.0, 0.0)); + tri.line_to((280.0, 200.0)); + tri.line_to((0.0, 200.0)); + tri.close_path(); + let polys = polygons(&tri); + assert!((position("shape", b, CENTRE, &polys, Point::new(140.0, 50.0)) - 0.5).abs() < 1e-6); + } + + #[test] + fn the_ramp_runs_through_the_stops() { + let stops = [(0.0, Rgba::rgb(0, 0, 0)), (0.5, Rgba::rgb(128, 128, 128)), (1.0, Rgba::rgb(255, 255, 255))]; + let r = ramp(&stops); + assert_eq!(r.len(), 256); + assert_eq!(r[0], Rgba::rgb(0, 0, 0)); + assert_eq!(r[255], Rgba::rgb(255, 255, 255)); + assert!((r[64].r as i32 - 64).abs() <= 2); + assert!(ramp(&[(0.3, Rgba::rgb(9, 9, 9))]).iter().all(|c| *c == Rgba::rgb(9, 9, 9))); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index dc057fb9..58ba965c 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -820,3 +820,29 @@ fn compound_lines_draw_their_bands() { let tri = runs(Compound::Triple); assert!(close(tri.clone(), &[4.0, 8.0, 4.0]), "{tri:?}"); } + +#[test] +fn rect_path_gradients_draw_x_shaped_contours() { + // path="rect" from the centre, black to grey to white (linear): PowerPoint's colour position is + // the larger axis share, so 20 pt right of centre in a 280x200 box is 1/7 and 20 pt down 1/5. + use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop}; + let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) }; + let sh = Shape { + id: ShapeId(770), + xfrm: Some(Xfrm::new(0.0, 0.0, 280.0, 200.0)), + fill: Some(Fill::Gradient(Gradient { + stops: vec![stop(0.0, 0), stop(0.5, 128), stop(1.0, 255)], + shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] }, + rotate_with_shape: true, + })), + line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), + ..Default::default() + }; + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let v = |x: u32, y: u32| img.pixel(x, y)[0] as f64; + assert!((v(160, 100) - 255.0 / 7.0).abs() < 6.0, "{}", v(160, 100)); + assert!((v(140, 120) - 51.0).abs() < 6.0, "{}", v(140, 120)); + // Along the diagonal to a corner the colour follows the vertical share. + assert!((v(168, 120) - 51.0).abs() < 6.0, "{}", v(168, 120)); +} From 880b974cff1f57dc0c40dce6083db0ad7f9d5aea Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 18:00:38 -0400 Subject: [PATCH 21/49] Render: soft edges fade to the shape's outline (#289) The soft-edge mask was the bounding box shrunk and blurred, so ovals and other non-rectangular shapes kept hard edges where the box was wider, and a rectangle's fade began outside it. The mask is now the outline itself, shrunk by 0.85 r (a stroke punched out of the fill) and blurred by r/3: transparent at the outline, half opaque 0.85 r inside, as measured on PowerPoint's export of a 12 pt soft edge. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 13 +++++++++---- crates/render/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 52d90f3b..b4ada12a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -765,18 +765,23 @@ impl Renderer { } } if let Some(r) = soft { - // Feather: fade the shape's alpha towards the outline. + // Feather: the shape (fill and line) fades to nothing at its outline. Measured on + // PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of + // r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself. ctx.set_transform(m); ctx.push_layer( None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), None, None, - Some(blur(r * 0.5 * scale_of(m))), + Some(blur(r / 3.0 * scale_of(m))), ); ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); - let inset = Rect::new(0.0, 0.0, w, h).inset(-r * 0.6); - ctx.fill_rect(&inset); + ctx.fill_path(&outline); + ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)); + ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round)); + ctx.stroke_path(&outline); + ctx.pop_layer(); ctx.pop_layer(); ctx.pop_layer(); } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 58ba965c..cfe4dc9a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -846,3 +846,29 @@ fn rect_path_gradients_draw_x_shaped_contours() { // Along the diagonal to a corner the colour follows the vertical share. assert!((v(168, 120) - 51.0).abs() < 6.0, "{}", v(168, 120)); } + +#[test] +fn soft_edges_fade_to_the_outline() { + // An ellipse with a 12 pt soft edge: transparent at its outline, half opaque about 10 pt + // inside, opaque further in, as in PowerPoint's export (the fade follows the curve, not the + // bounding box). + let sh = Shape { + id: ShapeId(780), + xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)), + geom: Geom::Preset { name: "ellipse".into(), adj: vec![] }, + fill: Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))), + line: Some(deckcraft_model::style::Line { fill: Some(deckcraft_model::style::Fill::None), ..Default::default() }), + effects: Some(deckcraft_model::Effects { soft_edge: Some(12.0), ..Default::default() }), + ..Default::default() + }; + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let alpha = |x: u32, y: u32| 1.0 - img.pixel(x, y)[0] as f64 / 255.0; + // Top of the ellipse is at y = 100, x = 250. + assert!(alpha(250, 101) < 0.08, "edge {}", alpha(250, 101)); + let mid = alpha(250, 110); + assert!((0.3..0.7).contains(&mid), "0.85 r inside: {mid}"); + assert!(alpha(250, 130) > 0.97, "inside {}", alpha(250, 130)); + // Near the ends of the bounding box's top edge, outside the ellipse, nothing is drawn. + assert!(alpha(110, 105) < 0.01); +} From d8407c11f9589f5bcfbe4b24de9ad1a2fd58bd58 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 18:03:19 -0400 Subject: [PATCH 22/49] Tables: styles the file defines are read, drawn and kept (#292) * Tables: styles the file defines are read, drawn and kept PowerPoint writes the definition of every table style a deck uses into ppt/tableStyles.xml. DeckCraft mapped a handful of GUIDs to its own looks and drew any other style as Medium Style 2, so themed and light styles came out in the wrong colours, and dropped the definitions on save. The reader now keeps each definition (whole table, bands, first/last rows and columns, corner cells, table background; fills, theme fill and line references, borders, text colour and bold) on the presentation, and tables using one keep its GUID. The renderer applies the parts that cover each cell in the spec's order, a part's edge lines on its region's edges and its inside lines within it, and paints fills as real fills (gradients and see-through colours). The writer puts the definitions back as read. Co-Authored-By: Claude Opus 5.5 (1M context) * Tables: DeckCraft's own table styles still map back to their ids Co-Authored-By: Claude Opus 5.5 (1M context) * Fix a brace lost in the rebase --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/model/src/defaults.rs | 1 + crates/model/src/lib.rs | 3 + crates/model/src/table.rs | 106 ++++++++++++++++++++- crates/pptx/src/read/mod.rs | 73 ++++++++++++++ crates/pptx/src/read/shapes.rs | 6 +- crates/pptx/src/tables.rs | 19 +++- crates/pptx/src/write/mod.rs | 2 +- crates/pptx/tests/table_styles.rs | 84 ++++++++++++++++ crates/render/src/table.rs | 153 +++++++++++++++++++++++++++--- crates/render/src/tests.rs | 40 ++++++++ 10 files changed, 470 insertions(+), 17 deletions(-) create mode 100644 crates/pptx/tests/table_styles.rs diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 9b50ae27..8432ce4a 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -512,6 +512,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen first_slide_number: 1, next_id: next.max(256), embedded_fonts: vec![], + table_styles: vec![], raw_ext: None, }; if title_slide { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index ef65a797..75d47161 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -798,6 +798,9 @@ pub struct Presentation { pub next_id: u32, /// Embedded fonts (family names; bytes in `media`). pub embedded_fonts: Vec<(String, MediaId)>, + /// Table styles defined in the file (`ppt/tableStyles.xml`). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub table_styles: Vec, /// Unmodelled presentation-level XML kept for round-trip. #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, diff --git a/crates/model/src/table.rs b/crates/model/src/table.rs index 45de90b4..00c9cb5e 100644 --- a/crates/model/src/table.rs +++ b/crates/model/src/table.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -use crate::style::{Fill, Line}; +use crate::style::{ColorRef, Fill, Line}; use crate::text::{Anchor, TextBody}; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -199,6 +199,110 @@ impl Table { } } +/// A table style defined in the file (`ppt/tableStyles.xml`), as PowerPoint writes the built-in +/// styles a deck uses: how each part of a table (whole table, banded rows and columns, first and +/// last rows and columns, corner cells) is filled, bordered and lettered. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TableStyleDef { + /// The style's GUID (`a:tableStyleId` on tables). + pub id: String, + pub name: String, + pub parts: Vec<(TablePart, TablePartStyle)>, + /// `a:tblBg`: a fill behind the whole table (only `fill` and `fill_ref` are used). + pub background: Option, + /// The definition as read, written back unchanged. + #[serde(skip_serializing_if = "String::is_empty")] + pub raw: String, +} + +impl TableStyleDef { + pub fn part(&self, p: TablePart) -> Option<&TablePartStyle> { + self.parts.iter().find(|(k, _)| *k == p).map(|(_, s)| s) + } +} + +/// The parts of a table style, in the order they apply (later ones win). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TablePart { + WholeTbl, + Band1H, + Band2H, + Band1V, + Band2V, + LastCol, + FirstCol, + LastRow, + FirstRow, + SeCell, + SwCell, + NeCell, + NwCell, +} + +impl TablePart { + pub const ALL: [TablePart; 13] = [ + TablePart::WholeTbl, + TablePart::Band1H, + TablePart::Band2H, + TablePart::Band1V, + TablePart::Band2V, + TablePart::LastCol, + TablePart::FirstCol, + TablePart::LastRow, + TablePart::FirstRow, + TablePart::SeCell, + TablePart::SwCell, + TablePart::NeCell, + TablePart::NwCell, + ]; + + pub fn xml(self) -> &'static str { + match self { + TablePart::WholeTbl => "wholeTbl", + TablePart::Band1H => "band1H", + TablePart::Band2H => "band2H", + TablePart::Band1V => "band1V", + TablePart::Band2V => "band2V", + TablePart::LastCol => "lastCol", + TablePart::FirstCol => "firstCol", + TablePart::LastRow => "lastRow", + TablePart::FirstRow => "firstRow", + TablePart::SeCell => "seCell", + TablePart::SwCell => "swCell", + TablePart::NeCell => "neCell", + TablePart::NwCell => "nwCell", + } + } + + pub fn from_xml(s: &str) -> Option { + TablePart::ALL.into_iter().find(|p| p.xml() == s) + } +} + +/// One part of a table style. A theme reference (`a:fillRef`, `a:lnRef`) is kept as the theme's +/// style index with its colour. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TablePartStyle { + pub fill: Option, + pub fill_ref: Option<(u32, ColorRef)>, + /// Left, right, top, bottom, inside horizontal, inside vertical. + pub borders: [Option; 6], + pub text_color: Option, + pub bold: Option, + pub italic: Option, +} + +/// A border of a table style part: a line, or a theme line style reference. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum TableBorder { + Line { line: Line }, + Ref { idx: u32, color: ColorRef }, +} + /// Built-in table styles: (id, label). Styles are generated from the theme accents. pub fn table_styles() -> Vec<(String, String)> { let mut v = vec![("none".to_string(), "No Style, No Grid".to_string()), ("grid".to_string(), "No Style, Table Grid".to_string())]; diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 03d162f0..692cfe56 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -30,6 +30,8 @@ pub struct Imp<'a> { pub next: u32, pub slide_by_part: HashMap, pub custom_show_names: HashMap, + /// GUIDs (upper case) of the table styles the file defines. + pub table_style_ids: Vec, } impl Imp<'_> { @@ -230,6 +232,7 @@ pub fn import(bytes: &[u8]) -> Result { next: max_id.max(1), slide_by_part: HashMap::new(), custom_show_names: HashMap::new(), + table_style_ids: Vec::new(), }; // Slide ids first so hyperlinks anywhere can point at slides. let mut slide_ids: Vec<(u32, String, SlideId)> = vec![]; @@ -269,6 +272,13 @@ pub fn import(bytes: &[u8]) -> Result { if let Some(dts) = pres_el.child("defaultTextStyle") { p.default_text_style = dml::list_style(&mut imp, &pres_part, dts); } + if let Some(r) = pres_part.rels.first_of("tableStyles") + && let Some(d) = load(&pkg, &r.target) + { + let part = Part::new(&pkg, &r.target, &d); + p.table_styles = table_styles(&mut imp, &part, &d.root); + imp.table_style_ids = p.table_styles.iter().map(|s| s.id.clone()).collect(); + } // Masters and layouts. let mut layout_by_part: HashMap = HashMap::new(); @@ -505,6 +515,69 @@ fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut } } +/// `a:tblStyleLst`: the table styles the file defines. +fn table_styles(imp: &mut Imp, part: &Part, root: &El) -> Vec { + use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef}; + root.children_named("tblStyle") + .take(256) + .filter_map(|st| { + let id = st.attr("styleId")?.to_ascii_uppercase(); + let mut def = TableStyleDef { id, name: st.attr("styleName").unwrap_or("").to_string(), raw: part.keep(st), ..Default::default() }; + if let Some(bg) = st.child("tblBg") { + let mut ps = TablePartStyle::default(); + if let Some(f) = bg.elements().find(|e| e.local().ends_with("Fill")) { + ps.fill = dml::fill_el(imp, part, f); + } + if let Some(fr) = bg.child("fillRef") { + ps.fill_ref = Some(( + fr.u32("idx").unwrap_or(0), + dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)), + )); + } + def.background = Some(ps); + } + for el in st.elements() { + let Some(kind) = TablePart::from_xml(el.local()) else { continue }; + let mut ps = TablePartStyle::default(); + if let Some(tx) = el.child("tcTxStyle") { + ps.text_color = dml::color(tx); + ps.bold = tx.attr("b").map(|v| v == "on"); + ps.italic = tx.attr("i").map(|v| v == "on"); + } + if let Some(tc) = el.child("tcStyle") { + if let Some(f) = tc.child("fill").and_then(|f| f.elements().next()) { + ps.fill = dml::fill_el(imp, part, f); + } + if let Some(fr) = tc.child("fillRef") { + ps.fill_ref = Some(( + fr.u32("idx").unwrap_or(0), + dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)), + )); + } + if let Some(bd) = tc.child("tcBdr") { + for (i, side) in ["left", "right", "top", "bottom", "insideH", "insideV"].iter().enumerate() { + let Some(b) = bd.child(side) else { continue }; + let border = if let Some(ln) = b.child("ln") { + Some(TableBorder::Line { line: dml::line(imp, part, ln) }) + } else { + b.child("lnRef").map(|r| TableBorder::Ref { + idx: r.u32("idx").unwrap_or(0), + color: dml::color(r).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)), + }) + }; + if let Some(slot) = ps.borders.get_mut(i) { + *slot = border; + } + } + } + } + def.parts.push((kind, ps)); + } + Some(def) + }) + .collect() +} + fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option { let bg = csld.child("bg")?; if let Some(pr) = bg.child("bgPr") { diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index c35d266c..ed6fd8e4 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -401,7 +401,11 @@ fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table { t.band_row = pr.bool("bandRow").unwrap_or(false); t.band_col = pr.bool("bandCol").unwrap_or(false); if let Some(id) = pr.child("tableStyleId").map(|e| e.text()) { - t.style = crate::tables::style_from_guid(id.trim()); + // A style the file defines is drawn from its definition (by its GUID). + // DeckCraft's own styles (written with GUIDs derived from their ids) map back to them. + let up = id.trim().to_ascii_uppercase(); + t.style = + if imp.table_style_ids.contains(&up) && !crate::tables::is_own_guid(&up) { up } else { crate::tables::style_from_guid(id.trim()) }; } } t.cols = tbl diff --git a/crates/pptx/src/tables.rs b/crates/pptx/src/tables.rs index 0025bd61..24b011c3 100644 --- a/crates/pptx/src/tables.rs +++ b/crates/pptx/src/tables.rs @@ -47,6 +47,12 @@ fn own_guid(style: &str) -> String { guid(&format!("deckcraft-table-style:{style}")) } +/// Is `g` the GUID DeckCraft writes for one of its own styles? +pub fn is_own_guid(g: &str) -> bool { + let up = g.to_ascii_uppercase(); + deckcraft_model::table::table_styles().iter().any(|(id, _)| own_guid(id) == up) +} + /// Table style GUID → our style id (or the GUID itself when unknown). pub fn style_from_guid(g: &str) -> String { let up = g.to_ascii_uppercase(); @@ -198,8 +204,9 @@ fn style_def(w: &mut W, id: &str, name: &str) { w.close("a:tblStyle"); } -/// `ppt/tableStyles.xml` with definitions for the styles in `used`. -pub fn table_styles_xml(used: &[String]) -> Vec { +/// `ppt/tableStyles.xml` with definitions for the styles in `used`: ours, generated, and those the +/// opened file defined (`defs`), written back as they were read. +pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableStyleDef]) -> Vec { let mut w = W::new(); w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}")); let names = deckcraft_model::table::table_styles(); @@ -209,6 +216,12 @@ pub fn table_styles_xml(used: &[String]) -> Vec { continue; } done.push(s); + if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s)) + && crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle")) + { + w.raw(&d.raw); + continue; + } let (_, define) = guid_for_style(s); if !define { continue; @@ -231,7 +244,7 @@ mod tests { "nwCell", ]; let used: Vec = deckcraft_model::table::table_styles().into_iter().map(|(id, _)| id).collect(); - let d = crate::xml::parse(&table_styles_xml(&used)).unwrap(); + let d = crate::xml::parse(&table_styles_xml(&used, &[])).unwrap(); let mut n = 0; for st in d.root.children_named("tblStyle") { n += 1; diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 0329e2ef..7fb6a713 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -512,7 +512,7 @@ pub fn export(p: &Presentation) -> Result, PptxError> { pres.rels.add("tableStyles", "tableStyles.xml", false); x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p)); x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml()); - let ts = crate::tables::table_styles_xml(&x.table_styles); + let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles); x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts); let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids); x.pkg.xml_part("ppt/presentation.xml", CT_PRES, pxml); diff --git a/crates/pptx/tests/table_styles.rs b/crates/pptx/tests/table_styles.rs new file mode 100644 index 00000000..015543f2 --- /dev/null +++ b/crates/pptx/tests/table_styles.rs @@ -0,0 +1,84 @@ +//! Fills and styles read from decks written by other tools. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_geom::Xfrm; +use deckcraft_model::style::Fill; +use deckcraft_model::{Shape, ShapeId, defaults}; + +const STYLE_GUID: &str = "{ABCDEF01-2345-4678-9ABC-DEF012345678}"; + +/// A table style definition as PowerPoint writes one: red table background, see-through white +/// bands, 2 pt blue outer borders and no inside lines. +fn style_xml() -> String { + format!( + r#" +"# + ) +} + +/// An exported deck with a 3x2 table using the probe style. +fn table_deck() -> Vec { + let mut p = defaults::new_presentation(None); + let id = ShapeId(p.alloc_id()); + let first = p.slides[0].id; + let mut t = deckcraft_model::Table::new(3, 2, 300.0, 30.0); + t.style = "medium2-accent1".into(); + p.slide_mut(first).expect("slide").shapes.push(Shape { + id, + name: "Styled".into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 300.0, 90.0)), + kind: deckcraft_model::ShapeKind::Table(t), + ..Default::default() + }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + // Point the table at the probe style and replace the style part. + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + let name = f.name().to_string(); + if name == "ppt/tableStyles.xml" { + d = style_xml().into_bytes(); + } else if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") { + let s = String::from_utf8(d).expect("utf8"); + let start = s.find("").map(|i| i + "".len()); + let end = s.find(""); + d = match (start, end) { + (Some(a), Some(b)) => format!("{}{}{}", &s[..a], STYLE_GUID, &s[b..]).into_bytes(), + _ => s.into_bytes(), + }; + } + out.start_file(name, o).expect("start"); + out.write_all(&d).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +#[test] +fn table_styles_the_file_defines_are_read_used_and_kept() { + use deckcraft_model::table::{TableBorder, TablePart}; + let p = deckcraft_pptx::import(&table_deck()).expect("import"); + let def = p.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("the probe style is read"); + assert_eq!(def.name, "Probe Style"); + let bg = def.background.as_ref().expect("table background"); + assert!(matches!(bg.fill_ref, Some((1, _)))); + let whole = def.part(TablePart::WholeTbl).expect("whole table"); + assert_eq!(whole.bold, Some(true)); + assert!(whole.text_color.is_some()); + assert!(matches!(&whole.borders[0], Some(TableBorder::Line { line }) if line.width == Some(2.0))); + assert!(matches!(&whole.borders[4], Some(TableBorder::Line { line }) if matches!(line.fill, Some(Fill::None)))); + assert!(matches!(def.part(TablePart::Band1H).and_then(|b| b.fill.clone()), Some(Fill::Solid { .. }))); + // The table keeps the style's GUID, so it's drawn from the definition. + let s = p.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("table"); + let deckcraft_model::ShapeKind::Table(t) = &s.kind else { panic!("table") }; + assert_eq!(t.style, STYLE_GUID); + // Saving writes the definition back, unchanged. + let again = deckcraft_pptx::export(&p).expect("export"); + let q = deckcraft_pptx::import(&again).expect("reimport"); + let def2 = q.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("kept on save"); + assert_eq!(def2.parts, def.parts); +} diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index fa531a2c..30770da4 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -3,7 +3,7 @@ use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_model::resolve::Ctx; use deckcraft_model::style::{ColorRef, Fill}; -use deckcraft_model::table::{Cell, Table}; +use deckcraft_model::table::{Cell, Table, TableBorder, TablePart, TablePartStyle, TableStyleDef}; use deckcraft_model::text::{Anchor, TextBody, TextDir}; use deckcraft_model::{Presentation, Shape}; use deckcraft_text::Fields; @@ -18,6 +18,12 @@ pub struct CellLook { pub text: Option, pub bold: bool, pub border: Option<(Rgba, f64)>, + /// Left, right, top and bottom borders from a file-defined style (a fully transparent colour + /// means no border); `None` falls back to `border`. + pub sides: [Option<(Rgba, f64)>; 4], + /// A file-defined style's fill as a paint (gradients, see-through colours), with its + /// placeholder colour; drawn instead of `fill`. + pub paint: Option<(Fill, Option)>, } fn accent(ctx: &Ctx, style: &str) -> Rgba { @@ -33,7 +39,109 @@ fn accent(ctx: &Ctx, style: &str) -> Rgba { ctx.color(&ColorRef::scheme(slot), None) } +/// The look of cell (`r`, `c`) from a table style the file defines: each part that applies to +/// the cell (whole table, bands, first and last rows and columns, corners, in that order) sets the +/// fill, text and the borders on its region's edges (its inside lines within it). +fn look_def(ctx: &Ctx, def: &TableStyleDef, t: &Table, r: usize, c: usize) -> CellLook { + let (rows, cols) = (t.rows.len(), t.cols.len()); + let (r1, c1) = t.cell(r, c).map(|cell| (r + cell.row_span.max(1) as usize - 1, c + cell.grid_span.max(1) as usize - 1)).unwrap_or((r, c)); + let (r1, c1) = (r1.min(rows.saturating_sub(1)), c1.min(cols.saturating_sub(1))); + let last_r = rows.saturating_sub(1); + let last_c = cols.saturating_sub(1); + let header = t.first_row && r == 0; + let total = t.last_row && r1 == last_r && rows > 1; + let first_col = t.first_col && c == 0; + let last_col = t.last_col && c1 == last_c && cols > 1; + let body_r = r.saturating_sub(usize::from(t.first_row)); + let body_c = c.saturating_sub(usize::from(t.first_col)); + let mut look = CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None }; + for part in TablePart::ALL { + // (applies, region rows, region cols) + let (on, region) = match part { + TablePart::WholeTbl => (true, (0, last_r, 0, last_c)), + TablePart::Band1H => (t.band_row && !header && !total && body_r % 2 == 0, (r, r1, 0, last_c)), + TablePart::Band2H => (t.band_row && !header && !total && body_r % 2 == 1, (r, r1, 0, last_c)), + TablePart::Band1V => (t.band_col && !first_col && !last_col && body_c % 2 == 0, (0, last_r, c, c1)), + TablePart::Band2V => (t.band_col && !first_col && !last_col && body_c % 2 == 1, (0, last_r, c, c1)), + TablePart::LastCol => (last_col, (0, last_r, c, last_c)), + TablePart::FirstCol => (first_col, (0, last_r, 0, c1)), + TablePart::LastRow => (total, (r, last_r, 0, last_c)), + TablePart::FirstRow => (header, (0, r1, 0, last_c)), + TablePart::SeCell => (total && last_col, (r, r1, c, c1)), + TablePart::SwCell => (total && first_col, (r, r1, c, c1)), + TablePart::NeCell => (header && last_col, (r, r1, c, c1)), + TablePart::NwCell => (header && first_col, (r, r1, c, c1)), + }; + if !on { + continue; + } + let Some(ps) = def.part(part) else { continue }; + if let Some(p) = style_fill(ctx, ps) { + look.fill = fill_color(ctx, &p.0, p.1); + look.paint = Some(p); + } + if let Some(tc) = &ps.text_color { + look.text = Some(ctx.color(tc, None)); + } + if let Some(b) = ps.bold { + look.bold = b; + } + let (r0, rr1, c0, cc1) = region; + // Left, right, top, bottom of the cell: the part's edge or its inside line. + let pick = [if c == c0 { 0 } else { 5 }, if c1 == cc1 { 1 } else { 5 }, if r == r0 { 2 } else { 4 }, if r1 == rr1 { 3 } else { 4 }]; + for (side, i) in pick.iter().enumerate() { + if let (Some(Some(b)), Some(slot)) = (ps.borders.get(*i), look.sides.get_mut(side)) { + *slot = Some(border_look(ctx, b)); + } + } + } + look +} + +/// A style part's fill, a theme fill reference resolved to the theme's fill with its colour as +/// the placeholder colour. +fn style_fill(ctx: &Ctx, ps: &TablePartStyle) -> Option<(Fill, Option)> { + if let Some((idx, col)) = &ps.fill_ref { + let ph = ctx.color(col, None); + return Some(match ctx.master.theme.format.fills.get((*idx as usize).saturating_sub(1)) { + Some(f) if *idx > 0 => (f.clone(), Some(ph)), + _ if *idx == 0 => (Fill::None, None), + _ => (Fill::solid(ColorRef::rgb(ph)), None), + }); + } + ps.fill.clone().map(|f| (f, None)) +} + +/// A fill's colour for a table cell (gradients and pictures by their first colour). +fn fill_color(ctx: &Ctx, f: &Fill, ph: Option) -> Option { + match f { + Fill::None => Some(Rgba::TRANSPARENT), + Fill::Solid { color: c } => Some(ctx.color(c, ph)), + Fill::Gradient(g) => g.stops.first().map(|s| ctx.color(&s.color, ph)), + Fill::Pattern(p) => Some(ctx.color(&p.bg, ph)), + _ => None, + } +} + +/// A style border's colour and width (a transparent colour for no line). +fn border_look(ctx: &Ctx, b: &TableBorder) -> (Rgba, f64) { + match b { + TableBorder::Line { line } => match &line.fill { + Some(Fill::Solid { color: c }) => (ctx.color(c, None), line.width.unwrap_or(0.75)), + Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), line.width.unwrap_or(0.75)), + _ => (Rgba::TRANSPARENT, 0.0), + }, + TableBorder::Ref { idx, color } => { + let w = ctx.master.theme.format.lines.get((*idx as usize).saturating_sub(1)).and_then(|l| l.width).unwrap_or(0.75); + if *idx == 0 { (Rgba::TRANSPARENT, 0.0) } else { (ctx.color(color, None), w) } + } + } +} + pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook { + if let Some(def) = ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) { + return look_def(ctx, def, t, r, c); + } let a = accent(ctx, &t.style); let kind = t.style.split('-').next().unwrap_or("medium2"); let header = t.first_row && r == 0; @@ -47,19 +155,23 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook { let white = Rgba::WHITE; let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None); match kind { - "none" => CellLook { fill: None, text: None, bold: false, border: None }, - "grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)) }, + "none" => CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None }, + "grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)), sides: [None; 4], paint: None }, "light1" => CellLook { fill: if band || band_c { Some(tint(20000)) } else { None }, text: None, bold: header || total || first_col || last_col, border: if header { Some((a, 1.0)) } else { None }, + sides: [None; 4], + paint: None, }, "light2" => CellLook { fill: if header { Some(a) } else { None }, text: if header { Some(white) } else { None }, bold: header || total || first_col, border: Some((a, 1.0)), + sides: [None; 4], + paint: None, }, "dark1" => CellLook { fill: Some(if header { @@ -72,12 +184,16 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook { text: Some(white), bold: header || total || first_col, border: Some((white, 1.0)), + sides: [None; 4], + paint: None, }, "medium4" => CellLook { fill: Some(if header || band || band_c { tint(40000) } else { tint(20000) }), text: None, bold: header || total || first_col || last_col, border: Some((a, 0.75)), + sides: [None; 4], + paint: None, }, _ => CellLook { // medium2: strong header, banded light tints, white grid. @@ -91,6 +207,8 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook { text: if header || total || first_col || last_col { Some(white) } else { None }, bold: header || total || first_col || last_col, border: Some((white, 1.0)), + sides: [None; 4], + paint: None, }, } } @@ -182,6 +300,14 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> { pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) { let _ = pres; let rects = cell_rects(t, &row_heights(rctx, t, fields)); + // A file-defined style's table background, behind every cell. + if let Some(def) = rctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) + && let Some((bg, ph)) = def.background.as_ref().and_then(|b| style_fill(rctx, b)) + && let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) + { + ctx.set_transform(m); + crate::paint::fill_path(ctx, rctx, &bg, ph, &all.to_path(0.1), all, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres); + } for (r, c, rect) in &rects { let Some(cell) = t.cell(*r, *c) else { continue }; let lk = look(rctx, t, *r, *c); @@ -190,12 +316,17 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, Some(f) => { crate::paint::fill_path(ctx, rctx, f, None, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres) } - None => { - if let Some(fc) = lk.fill { - ctx.set_paint(color(fc, 1.0)); - ctx.fill_rect(rect); + None => match &lk.paint { + Some((f, ph)) => { + crate::paint::fill_path(ctx, rctx, f, *ph, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres) } - } + None => { + if let Some(fc) = lk.fill.filter(|f| f.a > 0) { + ctx.set_paint(color(fc, 1.0)); + ctx.fill_rect(rect); + } + } + }, } } // Borders: explicit cell borders, else the style's grid. @@ -216,9 +347,9 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, Some(Fill::None) => continue, _ => continue, }, - None => match lk.border { - Some(b) => b, - None => continue, + None => match lk.sides.get(i).copied().flatten().or(lk.border) { + Some(b) if b.0.a > 0 && b.1 > 0.0 => b, + _ => continue, }, }; let mut p = BezPath::new(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index cfe4dc9a..16b3ccd0 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -872,3 +872,43 @@ fn soft_edges_fade_to_the_outline() { // Near the ends of the bounding box's top edge, outside the ellipse, nothing is drawn. assert!(alpha(110, 105) < 0.01); } + +#[test] +fn a_file_defined_table_style_draws_its_parts() { + // Red table background (tblBg), see-through white on banded rows, a 4 pt blue left border and + // no inside lines: drawn from the definition, not DeckCraft's own look for the style. + use deckcraft_model::style::{Fill, Line}; + use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef}; + let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b)); + let mut t = Table::new(4, 2, 400.0, 50.0); + t.style = "{ABCDEF01-2345-4678-9ABC-DEF012345678}".into(); + t.first_row = false; + t.band_row = true; + let line = |c: ColorRef, w| TableBorder::Line { line: Line { fill: Some(Fill::solid(c)), width: Some(w), ..Default::default() } }; + let mut whole = TablePartStyle { fill: Some(Fill::None), ..Default::default() }; + whole.borders[0] = Some(line(rgb(0, 0, 255), 4.0)); + whole.borders[4] = Some(TableBorder::Line { line: Line { fill: Some(Fill::None), ..Default::default() } }); + let see_through = ColorRef::rgb(deckcraft_color::Rgba { r: 255, g: 255, b: 255, a: 51 }); + let band = TablePartStyle { fill: Some(Fill::solid(see_through)), ..Default::default() }; + let def = TableStyleDef { + id: t.style.clone(), + name: "Probe".into(), + parts: vec![(TablePart::WholeTbl, whole), (TablePart::Band1H, band)], + background: Some(TablePartStyle { fill: Some(Fill::solid(rgb(255, 0, 0))), ..Default::default() }), + raw: String::new(), + }; + let tbl = Shape { id: ShapeId(790), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Table(t), ..Default::default() }; + let mut p = deck_with(vec![tbl]); + p.table_styles.push(def); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let px = |x, y| img.pixel(x, y); + // Row 1 (band1H): red with 20 % white over it; row 2 (band2H, undefined): plain red. + let banded = px(300, 125); + assert!(banded[0] > 250 && (40..65).contains(&banded[1]), "{banded:?}"); + assert_eq!(px(300, 175)[..3], [255, 0, 0]); + // No line between the rows; a blue left border. + let edge = px(300, 150); + assert!(edge[0] > 240, "no inside line: {edge:?}"); + let left = px(100, 175); + assert!(left[2] > 200 && left[0] < 60, "{left:?}"); +} From 16798365401f2e8381264e3047208e0cd2c8b341 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 18:05:12 -0400 Subject: [PATCH 23/49] Tables: the table's own fill (a:tblPr fill) is drawn behind its cells (#294) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/model/src/table.rs | 4 ++++ crates/pptx/src/read/shapes.rs | 1 + crates/pptx/src/write/shapes.rs | 3 +++ crates/pptx/tests/roundtrip.rs | 16 ++++++++++++++++ crates/render/src/table.rs | 21 ++++++++++++++------- crates/render/src/tests.rs | 19 +++++++++++++++++++ 6 files changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/model/src/table.rs b/crates/model/src/table.rs index 00c9cb5e..2dd46e3b 100644 --- a/crates/model/src/table.rs +++ b/crates/model/src/table.rs @@ -55,6 +55,9 @@ pub struct Table { pub last_col: bool, pub band_row: bool, pub band_col: bool, + /// `a:tblPr` fill: a fill behind the whole table (over the style's own background). + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, } impl Default for Table { @@ -69,6 +72,7 @@ impl Default for Table { last_col: false, band_row: true, band_col: false, + background: None, } } } diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index ed6fd8e4..05a74388 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -394,6 +394,7 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table { let mut t = Table { style: "none".into(), first_row: false, band_row: false, ..Default::default() }; if let Some(pr) = tbl.child("tblPr") { + t.background = pr.elements().find(|e| e.local().ends_with("Fill") && e.local() != "noFill").and_then(|f| dml::fill_el(imp, part, f)); t.first_row = pr.bool("firstRow").unwrap_or(false); t.first_col = pr.bool("firstCol").unwrap_or(false); t.last_row = pr.bool("lastRow").unwrap_or(false); diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 9b7309c0..f8e415dd 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -494,6 +494,9 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) { .t("bandRow", t.band_row) .t("bandCol", t.band_col), ); + if let Some(f) = &t.background { + dml::fill(w, x, o, f); + } w.elt("a:tableStyleId", &guid); w.close("a:tblPr"); w.open0("a:tblGrid"); diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 66576e7a..08fcbdc0 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -780,3 +780,19 @@ fn line_break_options_round_trip() { assert_eq!(props.latin_line_break, Some(true)); assert_eq!(props.hanging_punct, Some(false)); } + +#[test] +fn a_table_fill_round_trips() { + let mut p = defaults::new_presentation(None); + let mut t = Table::new(2, 2, 200.0, 40.0); + t.background = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 112, 192)))); + let mut sh = shape(&mut p, "Filled table", 10.0, 10.0, 200.0, 80.0); + sh.kind = deckcraft_model::ShapeKind::Table(t); + let first = p.slides[0].id; + p.slide_mut(first).expect("slide").shapes.push(sh); + let q = round(&p); + let deckcraft_model::ShapeKind::Table(t) = &by_name(&q.slides[0], "Filled table").kind else { panic!("table") }; + assert!( + matches!(&t.background, Some(Fill::Solid { color }) if matches!(color.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(0, 112, 192))) + ); +} diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index 30770da4..61eea5b6 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -300,13 +300,20 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> { pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) { let _ = pres; let rects = cell_rects(t, &row_heights(rctx, t, fields)); - // A file-defined style's table background, behind every cell. - if let Some(def) = rctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) - && let Some((bg, ph)) = def.background.as_ref().and_then(|b| style_fill(rctx, b)) - && let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) - { - ctx.set_transform(m); - crate::paint::fill_path(ctx, rctx, &bg, ph, &all.to_path(0.1), all, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres); + // Table backgrounds behind every cell: a file-defined style's, then the table's own fill. + let style_bg = rctx + .pres + .table_styles + .iter() + .find(|d| d.id.eq_ignore_ascii_case(&t.style)) + .and_then(|d| d.background.as_ref()) + .and_then(|b| style_fill(rctx, b)); + let own_bg = t.background.clone().map(|f| (f, None)); + if let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) { + for (bg, ph) in style_bg.into_iter().chain(own_bg) { + ctx.set_transform(m); + crate::paint::fill_path(ctx, rctx, &bg, ph, &all.to_path(0.1), all, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres); + } } for (r, c, rect) in &rects { let Some(cell) = t.cell(*r, *c) else { continue }; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 16b3ccd0..5f87689b 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -912,3 +912,22 @@ fn a_file_defined_table_style_draws_its_parts() { let left = px(100, 175); assert!(left[2] > 200 && left[0] < 60, "{left:?}"); } + +#[test] +fn a_table_fill_shows_behind_cells_without_their_own() { + // `a:tblPr` solidFill (tdf#127237): cells with no fill show the table's blue. + use deckcraft_model::style::Fill; + let mut t = Table::new(2, 2, 200.0, 50.0); + t.style = "none".into(); + t.background = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 112, 192)))); + for r in 0..2 { + for c in 0..2 { + t.cell_mut(r, c).unwrap().fill = Some(Fill::None); + } + } + let tbl = Shape { id: ShapeId(795), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), kind: ShapeKind::Table(t), ..Default::default() }; + let p = deck_with(vec![tbl]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + assert_eq!(img.pixel(150, 160)[..3], [0, 112, 192]); + assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]); +} From fd2f8146b4222da9ba240db9714fb5433e7db0cb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 18:06:30 -0400 Subject: [PATCH 24/49] Bullets: an installed symbol font draws its own glyph (#293) Bullets in Wingdings, Wingdings 2/3, Symbol and other symbol fonts were always swapped for a look-alike from one Wingdings table, so a Wingdings 2 star (0xEA) became a round dot. When the deck's symbol font is installed, its glyph is drawn (symbol fonts map character codes at U+F000 + code), as PowerPoint does; otherwise Wingdings 2 and 3 codes get their own look-alikes. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 59 ++++++++++++++++++++++++++++++++++++++++ crates/text/src/tests.rs | 25 +++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 93c52e4e..226d2bd1 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -493,6 +493,17 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, // Symbol fonts (Wingdings) aren't available: map common private-use bullets. let ch = map_symbol_bullet(char, props.bullet_font.as_deref()); bullet_face(db, &mut st, &ch); + + // A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found + // at U+F000 + the character code as such fonts map them; otherwise common symbol + // bullets are mapped to Unicode look-alikes. + let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) { + Some(c) => c.to_string(), + None => map_symbol_bullet(char, props.bullet_font.as_deref()), + }; + if !st.face.covers(ch.chars().next().unwrap_or('•')) { + st.face = db.face("Inter", "Regular"); + } Some((ch, st)) } Some(Bullet::AutoNum { scheme, start_at }) => { @@ -715,11 +726,59 @@ fn shape_item( } } +/// Is `family` a symbol font (characters at U+F000 + code)? +fn is_symbol_font(family: &str) -> bool { + let f = family.to_ascii_lowercase(); + f.starts_with("wingdings") || f == "symbol" || f.starts_with("webdings") || f.starts_with("marlett") +} + +/// The character to draw bullet `c` with the installed symbol font `face`, when `font` names a +/// symbol font that `face` is (not a substitute) and it has the glyph. +fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option { + let font = font?; + if !is_symbol_font(font) || !face.family.eq_ignore_ascii_case(font) { + return None; + } + let first = c.chars().next()?; + let pua = char::from_u32(0xF000 | ((first as u32) & 0xFF))?; + if face.covers(pua) { + Some(pua) + } else if face.covers(first) { + Some(first) + } else { + None + } +} + fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { let f = font.unwrap_or("").to_ascii_lowercase(); let first = c.chars().next().unwrap_or('•'); if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) { let code = (first as u32) & 0xff; + // Wingdings 2 and 3 put different shapes at the same codes. + if f.starts_with("wingdings 2") { + let m = match code { + 0x97 | 0x98 => '●', + 0x9e..=0xa1 => '■', + 0xa2..=0xa5 => '◆', + 0xea..=0xf3 => '★', + 0xb7..=0xbf => '○', + 0x50 => '✓', + _ => '•', + }; + return m.to_string(); + } + if f.starts_with("wingdings 3") { + let m = match code { + 0x7d | 0x75 => '►', + 0x7c | 0x74 => '◄', + 0x70 => '▲', + 0x71 => '▼', + 0xc6 | 0xc7 => '➔', + _ => '▶', + }; + return m.to_string(); + } let m = match code { 0xa7 => '▪', 0xd8 => '➢', diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 1b9a58c5..c55f274b 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -624,3 +624,28 @@ fn a_bullet_the_text_font_lacks_falls_back_to_a_face_that_has_it() { assert!(b.glyphs.iter().all(|g| g.0 != 0), "no missing-glyph box"); } } + +#[test] +fn symbol_font_bullets_map_by_font() { + // Wingdings 2 code 0xEA is a star, Wingdings 3 0x7D a right-pointing triangle; plain + // Wingdings keeps its own table (0xA7 is a small square). + let star = String::from_utf8(vec![0xEF, 0x83, 0xAA]).unwrap(); // U+F0EA + assert_eq!(map_symbol_bullet(&star, Some("Wingdings 2")), "★"); + assert_eq!(map_symbol_bullet("\u{7d}", Some("Wingdings 3")), "►"); + assert_eq!(map_symbol_bullet("\u{f0a7}", Some("Wingdings")), "▪"); + assert_eq!(map_symbol_bullet("•", Some("Arial")), "•"); + assert!(is_symbol_font("Wingdings 2") && is_symbol_font("Symbol") && !is_symbol_font("Arial")); +} + +#[test] +fn an_installed_symbol_font_draws_its_own_bullet() { + // Where the deck's symbol font is installed, its glyph is drawn (as PowerPoint does), not a + // look-alike. Skipped without Wingdings 2. + let db = FontDb::global(); + if !db.has_family("Wingdings 2") { + return; + } + let face = db.face("Wingdings 2", "Regular"); + assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Wingdings 2")), Some('\u{f0ea}')); + assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Arial")), None); +} From a520dd4d1d5ed06ead85d331c6d51817427e9b8a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 18:09:46 -0400 Subject: [PATCH 25/49] Render: shadows, inner shadows and glows spread like PowerPoint's (#305) * Render: shadows, inner shadows and glows spread like PowerPoint's Measured on PowerPoint's export of probe shapes: - outer and inner shadows are blurred by a third of the blur radius (was half), so their profile matches pixel for pixel; - an inner shadow falls inside along the edges its direction points to (45 degrees: bottom and right); DeckCraft put it on the opposite edges; - a glow of radius r is half strength about r/2 out and gone by r (was a wider, softer halo). Co-Authored-By: Claude Opus 5.5 (1M context) * Render tests: probe the outer shadow where the narrower blur still reaches Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 16 ++++++--- crates/render/src/tests.rs | 69 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b4ada12a..b4938893 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -793,11 +793,14 @@ impl Renderer { ctx.push_clip_layer(&outline); let c = rctx.color(&sh.color, fx_ph); let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; - ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m)))); + // As PowerPoint draws it (measured on its export): the shadow falls inside along the + // edges it points to (45 degrees: the bottom and right), blurred by a third of the + // blur radius. + ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * scale_of(m)))); ctx.set_paint(color(c, 1.0)); let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1); let mut hole = outline.clone(); - hole.apply_affine(Affine::translate(d)); + hole.apply_affine(Affine::translate(-d)); ring.extend(hole.iter()); ctx.set_fill_rule(peniko::Fill::EvenOdd); ctx.fill_path(&ring); @@ -868,7 +871,8 @@ impl Renderer { // Shadow offset is in slide space (doesn't rotate with the shape unless asked). let sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local }; ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur * 0.5 * k))); + // Blurred by a third of the blur radius, as PowerPoint's export shows. + ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k))); ctx.set_transform(sm); ctx.set_paint(color(c.with_alpha(255), 1.0)); ctx.fill_path(&silhouette); @@ -877,10 +881,12 @@ impl Renderer { if let Some(g) = &e.glow { let c = rctx.color(&g.color, ph); ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k))); + // Measured on PowerPoint's export: a glow of radius r is half strength about half of r + // out from the outline and gone by r. + ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * k))); ctx.set_transform(m); ctx.set_paint(color(c.with_alpha(255), 1.0)); - ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round)); + ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round)); ctx.stroke_path(&silhouette); ctx.fill_path(&silhouette); ctx.pop_layer(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 5f87689b..9a0c6fef 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -119,9 +119,10 @@ fn effects_tables_charts_render() { all.extend(charts); let p = deck_with(all); let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); - // Shadow darkens below-right of the shape. - let px = img.pixel(258, 156); - assert!(px[0] < 250, "{px:?}"); + // Shadow darkens below-right of the shape (grey, past the glow), blurred by a third of its + // 8 pt blur radius. + let px = img.pixel(255, 155); + assert!(px[0] < 240 && px[0] == px[2], "{px:?}"); } #[test] @@ -931,3 +932,65 @@ fn a_table_fill_shows_behind_cells_without_their_own() { assert_eq!(img.pixel(150, 160)[..3], [0, 112, 192]); assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]); } + +/// A white 280x200 pt rectangle at (20, 20) with `effects`, rendered at 1 px/pt. +fn effect_box(effects: deckcraft_model::Effects) -> Image { + use deckcraft_model::style::{Fill, Line}; + let sh = Shape { + id: ShapeId(800), + xfrm: Some(Xfrm::new(20.0, 20.0, 280.0, 200.0)), + fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))), + line: Some(Line { fill: Some(Fill::None), ..Default::default() }), + effects: Some(effects), + ..Default::default() + }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +fn shadow(blur: f64, dist: f64, dir: f64) -> deckcraft_model::style::Shadow { + deckcraft_model::style::Shadow { + color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)), + blur, + dist, + dir, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + } +} + +#[test] +fn shadows_and_glows_spread_like_powerpoints() { + let red = |img: &Image, x: u32, y: u32| img.pixel(x, y)[0] as i32; + // Outer shadow, 9 pt blur, no offset: PowerPoint's export reads 144, 204, 238 at 1, 3 and 5 + // px outside the left edge. + let img = effect_box(deckcraft_model::Effects { outer_shadow: Some(shadow(9.0, 0.0, 0.0)), ..Default::default() }); + for (d, want) in [(1, 144), (3, 204), (5, 238)] { + assert!((red(&img, 20 - d, 120) - want).abs() <= 6, "outer {d}: {}", red(&img, 20 - d, 120)); + } + // Inner shadow pointing down-right (45 degrees): it falls inside along the bottom and right + // edges, not the top and left. + let mut sh = shadow(0.0, 10.0, 45.0); + sh.inner = true; + let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() }); + assert!(red(&img, 296, 120) < 20, "right edge {}", red(&img, 296, 120)); + assert!(red(&img, 160, 216) < 20, "bottom edge {}", red(&img, 160, 216)); + assert!(red(&img, 24, 120) > 250 && red(&img, 160, 24) > 250, "top and left stay clear"); + // Inner shadow with a 9 pt blur and no offset: the same profile as the outer one, inside. + let mut sh = shadow(9.0, 0.0, 0.0); + sh.inner = true; + let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() }); + for (d, want) in [(0, 144), (2, 204), (4, 238)] { + assert!((red(&img, 20 + d, 120) - want).abs() <= 6, "inner {d}: {}", red(&img, 20 + d, 120)); + } + // Glow of radius 10 pt: nearly solid 1 px out, about half strength 5 px out, gone by 10. + let glow = deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)), radius: 10.0 }; + let img = effect_box(deckcraft_model::Effects { glow: Some(glow), ..Default::default() }); + assert!(red(&img, 19, 120) < 20); + assert!((60..170).contains(&red(&img, 15, 120)), "{}", red(&img, 15, 120)); + assert!(red(&img, 10, 120) > 245); +} From 5835f0a106f50dccb69307c842a03118a258c8e3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 18:11:10 -0400 Subject: [PATCH 26/49] Text: no Latin ligatures, and kerning only from the run's kern size (#309) PowerPoint keeps fi, ffi and Calibri's ti apart and kerns a run only when a:rPr/@kern is set and the size reaches it; DeckCraft shaped with the font's defaults (ligatures and kerning always on), so Calibri text read differently and kern="0" text came out narrower. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 27 ++++++++++++++-- crates/text/src/tests.rs | 69 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 226d2bd1..32ef2f18 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -203,6 +203,8 @@ struct Style { cs: Option, /// Heading font (`+mj-…`): the theme's major font set applies. major: bool, + /// Pair kerning on: `a:rPr/@kern` is set and the size reaches it. + kern: bool, } /// One character of a paragraph after shaping. @@ -314,6 +316,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> Styl ea, cs, major, + kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale), } } @@ -613,7 +616,8 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa // Items: maximal ranges of one level, face, script and shaping style (case mapping, language). let upper = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.caps != Caps::None); let lang = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).and_then(|st| st.lang.as_deref()); - let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i)); + let kern = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.kern); + let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i), kern(i)); let mut a = 0; while a < n { let mut b = a + 1; @@ -629,6 +633,24 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa /// Size of superscript and subscript text relative to the run's size. const SUB_SUPER: f64 = 0.66; +/// The OpenType features to change from the shaper's defaults for an item, as PowerPoint draws +/// it: kerning only where the run asks for it (`kern`), and no standard or contextual ligatures in +/// Latin, Greek and Cyrillic ("fi", "ffi" and Calibri's "ti" stay apart). Other scripts need +/// their ligatures (Arabic lam-alef), and emoji sequences are joined by them. +fn shaping_features(cells: &[Cell], styles: &[Style], script: Option) -> Vec { + let mut f = vec![]; + if !cells.first().and_then(|c| styles.get(c.style)).is_some_and(|st| st.kern) { + f.extend(deckcraft_fonts::feature("-kern")); + } + let alphabetic = matches!(script.as_ref(), Some(b"Latn" | b"Grek" | b"Cyrl")); + let emoji = cells.iter().any(|c| matches!(c.ch as u32, 0x200D | 0x20E3 | 0xFE0F | 0x2600..=0x27BF | 0x1F000..=0x1FAFF | 0xE0020..=0xE007F)); + if alphabetic && !emoji { + f.extend(deckcraft_fonts::feature("-liga")); + f.extend(deckcraft_fonts::feature("-clig")); + } + f +} + /// Shape cells `range` (one item) and hang each cluster's glyphs on its first cell, sharing the /// cluster's advance among its graphemes (by the ligature's GDEF carets when it has them). #[allow(clippy::too_many_arguments)] @@ -649,7 +671,8 @@ fn shape_item( let Some(face) = faces.get(fi) else { return }; let rtl = level % 2 == 1; let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else { return }; - let params = ShapeParams { features: &[], rtl, script, language: lang }; + let features = shaping_features(cells.get(range.clone()).unwrap_or(&[]), styles, script); + let params = ShapeParams { features: &features, rtl, script, language: lang }; let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, ¶ms, |ch| if upper { ch.to_uppercase().next().unwrap_or(ch) } else { ch }); let upem = face.upem.max(1.0); let scale_of = |cell: &Cell| { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index c55f274b..724e63bc 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -45,6 +45,75 @@ fn bullets_are_drawn_for_body_text() { assert_eq!(l.para_lines.len(), 2); } +/// Glyph count and width of `text` laid out in one line of `family` at 40 pt, kern threshold `kern`. +fn glyphs_and_width(family: &str, text: &str, kern: Option) -> (usize, f64) { + let (p, mut sh) = setup(text, 2000.0, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + para.props.bullet = Some(Bullet::None); + for r in &mut para.runs { + r.props.font = Some(family.into()); + r.props.size = Some(40.0); + r.props.kern = kern; + } + let l = lay(&p, &sh, 2000.0, 300.0); + let n = l.runs.iter().map(|r| r.glyphs.len()).sum(); + (n, l.lines[0].extent.1 - l.lines[0].extent.0) +} + +/// The first installed or bundled family of `families` for which `pick` holds. +fn first_family(families: &[&str], pick: impl Fn(&FontFace) -> bool) -> Option { + let db = FontDb::global(); + families.iter().filter(|f| db.has_family(f)).map(|f| db.face(f, "Regular")).find(|f| pick(f)).map(|f| f.family.clone()) +} + +const LATIN_FAMILIES: &[&str] = &["Carlito", "Calibri", "DejaVu Sans", "Liberation Sans", "Inter", "Arial", "Cambria", "Times New Roman"]; + +#[test] +fn latin_text_draws_no_ligatures() { + // PowerPoint keeps "fi", "ffi" and Calibri's "ti" apart, although the fonts have them as + // standard ligatures. + let ligates = |f: &FontFace| deckcraft_fonts::shape(f, "office", &[], |c| c).len() < 6; + let Some(family) = first_family(LATIN_FAMILIES, ligates) else { + eprintln!("skipped: no font with an ffi ligature"); + return; + }; + let (n, _) = glyphs_and_width(&family, "office fifth", Some(0.0)); + assert_eq!(n, 12, "{family}: one glyph per character"); +} + +#[test] +fn pairs_are_kerned_only_from_the_kern_size() { + let kerns = |f: &FontFace| { + let w = |feat: &[deckcraft_fonts::Feature]| deckcraft_fonts::shape(f, "AVATAR", feat, |c| c).iter().map(|g| g.x_advance).sum::(); + w(&[]) != w(&deckcraft_fonts::feature("-kern").into_iter().collect::>()) + }; + let Some(family) = first_family(LATIN_FAMILIES, kerns) else { + eprintln!("skipped: no font that kerns AVATAR"); + return; + }; + // `kern` is the smallest size kerned; 0 is no kerning. + let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0)); + let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0)); + let (_, at) = glyphs_and_width(&family, "AVATAR WAVE", Some(40.0)); + let (_, above) = glyphs_and_width(&family, "AVATAR WAVE", Some(48.0)); + assert!(on < off - 2.0, "{family}: kerned {on} vs {off}"); + assert!((at - on).abs() < 0.01, "kerned at exactly the threshold size"); + assert!((above - off).abs() < 0.01, "40 pt is below a 48 pt threshold"); +} + +#[test] +fn ligatures_stay_for_scripts_that_need_them() { + let has = |f: &[deckcraft_fonts::Feature], tag: &str| f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag)); + let cells = + |s: &str| s.chars().map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true }).collect::>(); + let latin = shaping_features(&cells("office"), &[], Some(*b"Latn")); + assert!(has(&latin, "-liga") && has(&latin, "-clig") && has(&latin, "-kern")); + // Arabic needs its ligatures; emoji sequences are joined by them. + assert!(!has(&shaping_features(&cells("\u{644}\u{627}"), &[], Some(*b"Arab")), "-liga")); + assert!(!has(&shaping_features(&cells("a\u{1F469}\u{200D}\u{1F4BB}"), &[], Some(*b"Latn")), "-liga")); + assert!(!has(&shaping_features(&cells("12"), &[], None), "-liga")); +} + #[test] fn only_the_first_character_of_a_bullet_is_drawn() { // "••" (seen in the wild) draws one dot in PowerPoint. From dedb20890bc6a5250c135f0d525d9b9c58e2ad8c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 18:12:58 -0400 Subject: [PATCH 27/49] Text: WordArt transforms (prstTxWarp) draw, and their adjusts round-trip (#312) * Text warps: draw WordArt prstTxWarp presets, round-trip their adjusts All 40 DrawingML text-warp presets now render instead of drawing flat text. A warp is a list of bands over the shape's text rectangle (insets ignored): fill bands stretch the ink of their lines between a top and a bottom guide (positions by arc length, one horizontal scale for the whole text), follow bands (arch, circle, button) set lines at natural size along an ellipse arc, lines stacking outwards on parallel curves, with no wrapping. Lines are shared over multi-band presets in order (ceil(n/k) per band). The guide curves were fitted to PowerPoint renders of synthetic decks of "I" bars (black-box observation, defaults and changed adjusts, 1-3 lines); most presets match within about a point on a 420 x 200 pt box. - geom: warp module (guides, presets, band mapping) with unit tests - render: glyph outlines and underlines flattened, subdivided and mapped - text: follow warps lay out without wrapping - model/pptx: BodyProps.warp_adj read from and written to prstTxWarp/avLst Co-Authored-By: Claude Opus 5.5 (1M context) * Text warps: bands with fewer lines keep the fuller band's text height Multi-band fill warps (Deflate-Inflate, Button Pour) map a box as tall as the tallest band's ink onto every band, centred on the band's own ink, as PowerPoint does: a band holding one line next to a band of two keeps the line at the same size instead of stretching it over the whole band. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ROADMAP.md | 1 + crates/geom/src/lib.rs | 1 + crates/geom/src/warp.rs | 773 +++++++++++++++++++++++++++++++++ crates/geom/src/warp/tests.rs | 309 +++++++++++++ crates/model/src/resolve.rs | 1 + crates/model/src/text.rs | 5 + crates/pptx/src/read/dml.rs | 34 +- crates/pptx/src/write/dml.rs | 14 +- crates/pptx/tests/roundtrip.rs | 61 +++ crates/render/src/lib.rs | 13 +- crates/render/src/tests.rs | 81 ++++ crates/render/src/warp.rs | 209 +++++++++ crates/text/src/lib.rs | 4 +- docs/file-format-parity.md | 3 +- docs/gaps.md | 11 +- docs/parity-checklist.md | 6 +- 16 files changed, 1515 insertions(+), 11 deletions(-) create mode 100644 crates/geom/src/warp.rs create mode 100644 crates/geom/src/warp/tests.rs create mode 100644 crates/render/src/warp.rs diff --git a/ROADMAP.md b/ROADMAP.md index 801ddd84..c74bbdae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | | 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG | diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 038a6e52..dd41db37 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -8,6 +8,7 @@ pub mod morph; pub mod preset; +pub mod warp; pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2}; use serde::{Deserialize, Serialize}; diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs new file mode 100644 index 00000000..acdee999 --- /dev/null +++ b/crates/geom/src/warp.rs @@ -0,0 +1,773 @@ +//! WordArt text warps (DrawingML `a:bodyPr/a:prstTxWarp`). +//! +//! A warp preset is a list of bands laid over the shape's text rectangle (insets are ignored). +//! Text lines are shared out over the bands in order (the first ⌈n/k⌉ lines to the first band +//! and so on). A band either +//! - stretches its lines between a top and a bottom guide ([`Band::Fill`]): the ink box of the +//! band's lines fills the band vertically (a band whose ink is shorter than the tallest band's +//! keeps the same scale, centred), and horizontally the text is scaled so that the +//! widest band is filled along the mean length of its guides (one scale for the whole text), or +//! - sets its lines at their natural size along one guide ([`Band::Follow`], arches and circles): +//! glyphs keep their size, are placed by arc length (aligned as in the paragraph, the whole +//! guide being the line's width: insets don't count) and stand on +//! the guide's left normal; lines further from the guide stack outwards. Lines that are longer +//! than the guide are shrunk to fit. +//! +//! Positions along a guide are by arc length; a fill band maps the same fraction of its top and +//! bottom guide's length and interpolates between them. The guide curves were derived from +//! observation: synthetic decks of `I` bars were drawn with PowerPoint and every curve fitted to +//! the measured bar ends (most fit within a point on a 420 × 200 pt box). +use kurbo::{Arc, BezPath, PathEl, Point, Rect, Vec2}; + +/// Flattening tolerance for guides, in points. +const TOL: f64 = 0.02; + +/// A guide curve flattened to a polyline with cumulative arc lengths. +#[derive(Clone, Debug, Default)] +pub struct Guide { + pts: Vec, + cum: Vec, + closed: bool, +} + +impl Guide { + fn from_points(raw: impl IntoIterator, closed: bool) -> Guide { + let mut pts: Vec = Vec::new(); + let mut cum: Vec = Vec::new(); + for p in raw { + if !(p.x.is_finite() && p.y.is_finite()) { + continue; + } + match pts.last() { + Some(q) => { + let d = (p - *q).hypot(); + if d > 1e-9 { + cum.push(cum.last().copied().unwrap_or(0.0) + d); + pts.push(p); + } + } + None => { + pts.push(p); + cum.push(0.0); + } + } + if pts.len() > 100_000 { + break; + } + } + Guide { pts, cum, closed } + } + + /// A guide through `path` (any curves), flattened. + pub fn from_path(path: &BezPath, closed: bool) -> Guide { + let mut pts = Vec::new(); + kurbo::flatten(path.iter(), TOL, |el| match el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(p), + _ => {} + }); + Guide::from_points(pts, closed) + } + + /// Total arc length. + pub fn length(&self) -> f64 { + self.cum.last().copied().unwrap_or(0.0) + } + + /// Whether the guide is a closed loop (positions past its end wrap around). + pub fn is_closed(&self) -> bool { + self.closed + } + + /// Point and unit tangent at arc length `s`. Open guides continue along their end tangents + /// past either end; closed guides wrap. + pub fn at(&self, s: f64) -> (Point, Vec2) { + let len = self.length(); + let (Some(&first), Some(&last)) = (self.pts.first(), self.pts.last()) else { + return (Point::ZERO, Vec2::new(1.0, 0.0)); + }; + if self.pts.len() < 2 || len <= 1e-9 { + return (first, Vec2::new(1.0, 0.0)); + } + let s = if s.is_finite() { s } else { 0.0 }; + let s = if self.closed { s.rem_euclid(len) } else { s }; + let seg = |i: usize| -> (Point, Point, f64) { + let a = self.pts.get(i).copied().unwrap_or(first); + let b = self.pts.get(i + 1).copied().unwrap_or(last); + (a, b, self.cum.get(i).copied().unwrap_or(0.0)) + }; + let n = self.pts.len(); + let i = if s <= 0.0 { + 0 + } else if s >= len { + n - 2 + } else { + self.cum.partition_point(|c| *c <= s).saturating_sub(1).min(n - 2) + }; + let (a, b, c0) = seg(i); + let d = b - a; + let l = d.hypot(); + let t = if l > 1e-12 { d / l } else { Vec2::new(1.0, 0.0) }; + (a + t * (s - c0), t) + } + + /// The parallel curve `d` points away on the left of the direction of travel (above a + /// guide running left to right). + pub fn offset(&self, d: f64) -> Guide { + let n = self.pts.len(); + if n < 2 || !d.is_finite() || d == 0.0 { + return self.clone(); + } + let unit = |i: usize| -> Vec2 { + let (a, b) = (self.pts.get(i).copied().unwrap_or(Point::ZERO), self.pts.get(i + 1).copied().unwrap_or(Point::ZERO)); + let v = b - a; + let l = v.hypot(); + if l > 1e-12 { v / l } else { Vec2::ZERO } + }; + let pts = (0..n).map(|i| { + let before = if i > 0 { + unit(i - 1) + } else if self.closed { + unit(n - 2) + } else { + Vec2::ZERO + }; + let after = if i + 1 < n { + unit(i) + } else if self.closed { + unit(0) + } else { + Vec2::ZERO + }; + let t = before + after; + let l = t.hypot(); + let t = if l > 1e-12 { t / l } else { Vec2::new(1.0, 0.0) }; + self.pts.get(i).copied().unwrap_or(Point::ZERO) + Vec2::new(t.y, -t.x) * d + }); + Guide::from_points(pts.collect::>(), self.closed) + } + + /// The point at fraction `t` of the length. + pub fn at_frac(&self, t: f64) -> Point { + self.at(t * self.length()).0 + } +} + +/// Which line of a follow band sits on its guide. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LineAnchor { + /// The last line; earlier lines stack outwards (arch up, circle). + Last, + /// The first line; later lines stack below it (arch down). + First, + /// The middle of the band's lines. + Middle, +} + +#[derive(Clone, Debug)] +pub enum Band { + /// Text stretched between two guides. + Fill { top: Guide, bottom: Guide }, + /// Text at its natural size along a guide. + Follow { path: Guide, anchor: LineAnchor }, +} + +impl Band { + /// The length text is measured against: the mean of a fill band's guides. + pub fn length(&self) -> f64 { + match self { + Band::Fill { top, bottom } => (top.length() + bottom.length()) / 2.0, + Band::Follow { path, .. } => path.length(), + } + } +} + +/// A resolved warp: its bands in shape-local points. +#[derive(Clone, Debug)] +pub struct Warp { + pub bands: Vec, +} + +/// Presets whose text follows a guide at its natural size (no wrapping). +pub fn is_follow(name: &str) -> bool { + matches!(name, "textArchUp" | "textArchDown" | "textCircle" | "textButton") +} + +/// Default adjust values of a warp preset (`adj`, or `adj1`, `adj2`); empty for unknown presets. +pub fn adjust_defaults(name: &str) -> &'static [f64] { + match name { + "textPlain" => &[50_000.0], + "textStop" => &[25_000.0], + "textTriangle" | "textTriangleInverted" => &[50_000.0], + "textChevron" => &[25_000.0], + "textChevronInverted" => &[75_000.0], + "textRingInside" | "textRingOutside" => &[60_000.0], + "textArchUp" | "textCircle" | "textButton" => &[10_800_000.0], + "textArchDown" => &[0.0], + "textArchUpPour" | "textCirclePour" | "textButtonPour" => &[10_800_000.0, 50_000.0], + "textArchDownPour" => &[0.0, 25_000.0], + "textCurveUp" | "textCurveDown" => &[45_977.0], + "textCanUp" => &[85_000.0], + "textCanDown" => &[14_286.0], + "textWave1" | "textWave2" => &[12_500.0, 0.0], + "textDoubleWave1" | "textWave4" => &[6_250.0, 0.0], + "textInflate" | "textDeflate" => &[18_750.0], + "textInflateBottom" => &[60_000.0], + "textDeflateBottom" | "textDeflateTop" => &[50_000.0], + "textInflateTop" => &[40_000.0], + "textDeflateInflate" => &[35_000.0], + "textDeflateInflateDeflate" => &[25_000.0], + "textFadeRight" | "textFadeLeft" | "textFadeUp" | "textFadeDown" => &[33_333.0], + "textSlantUp" => &[55_556.0], + "textSlantDown" => &[44_444.0], + "textCascadeUp" | "textCascadeDown" => &[44_444.0], + _ => &[], + } +} + +/// Number of adjust values a warp preset takes. +pub fn adjust_count(name: &str) -> usize { + adjust_defaults(name).len() +} + +/// Whether `name` is a warp preset DeckCraft draws. +pub fn is_supported(name: &str) -> bool { + !adjust_defaults(name).is_empty() +} + +/// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle). +#[derive(Clone, Copy, Debug)] +enum Seg { + M(f64, f64), + L(f64, f64), + Q(f64, f64, f64, f64), + C(f64, f64, f64, f64, f64, f64), + /// Elliptical arc: centre, radii, start and sweep in degrees (y down: positive = clockwise). + A(f64, f64, f64, f64, f64, f64), +} + +enum Spec { + Fill(Vec, Vec), + Follow(Vec, LineAnchor), +} + +struct Adj<'a> { + raw: &'a [f64], + defaults: &'static [f64], +} + +impl Adj<'_> { + fn raw(&self, i: usize) -> f64 { + self.raw.get(i).copied().filter(|v| v.is_finite()).or_else(|| self.defaults.get(i).copied()).unwrap_or(0.0) + } + /// Adjust `i` as a fraction (1/100 000), pinned to `lo..=hi` (fractions). + fn frac(&self, i: usize, lo: f64, hi: f64) -> f64 { + (self.raw(i) / 100_000.0).clamp(lo, hi) + } + /// Adjust `i` as an angle in degrees (1/60 000), in 0..360. + fn angle(&self, i: usize) -> f64 { + (self.raw(i) / 60_000.0).rem_euclid(360.0) + } +} + +fn line(pts: &[(f64, f64)]) -> Vec { + pts.iter().enumerate().map(|(i, &(x, y))| if i == 0 { Seg::M(x, y) } else { Seg::L(x, y) }).collect() +} + +/// Quadratic from (0, y0) through control (0.5, c) to (1, y0). +fn quad(y0: f64, c: f64) -> Vec { + vec![Seg::M(0.0, y0), Seg::Q(0.5, c, 1.0, y0)] +} + +/// A sweep in (0, 360]: zero means a full turn. +fn turn(deg: f64) -> f64 { + let d = deg.rem_euclid(360.0); + if d < 1e-9 { 360.0 } else { d } +} + +/// Arc of the ellipse inscribed in the rectangle, scaled by `s` about its centre. +fn ellipse(s: f64, start: f64, sweep: f64) -> Vec { + vec![Seg::A(0.5, 0.5, 0.5 * s, 0.5 * s, start, sweep)] +} + +/// The arch-up arc for adjust angle `a`: symmetric about the top (270°), clockwise. +fn arch_up(a: f64) -> (f64, f64) { + (a, turn(2.0 * (270.0 - a))) +} + +/// The arch-down arc for adjust angle `a`: symmetric about the bottom (90°), counter-clockwise. +fn arch_down(a: f64) -> (f64, f64) { + (180.0 - a, -turn(180.0 - 2.0 * a)) +} + +/// The circle arc for adjust angle `a`: from `a` clockwise, symmetric about 0°. +fn circle(a: f64) -> (f64, f64) { + (a, turn(2.0 * (360.0 - a))) +} + +/// One cubic wave period from x0 to x1 at height y: up first when `k` > 0 (y decreases). +fn wave(out: &mut Vec, x0: f64, x1: f64, y: f64, k: f64) { + let l = x1 - x0; + out.push(Seg::C(x0 + l / 3.0, y - k, x0 + 2.0 * l / 3.0, y + k, x1, y)); +} + +/// Wave guides: `periods` cubic waves over the width, shifted apart by `shift` (fraction). +fn waves(a: f64, shift: f64, periods: usize, up_first: bool) -> Spec { + let d = 2.0 * shift; + let (top_x, bot_x) = if d >= 0.0 { ((0.0, 1.0 - d), (d, 1.0)) } else { ((-d, 1.0), (0.0, 1.0 + d)) }; + let k = if up_first { a * 10.0 / 3.0 } else { -a * 10.0 / 3.0 }; + let guide = |(x0, x1): (f64, f64), y: f64| { + let mut v = vec![Seg::M(x0, y)]; + let n = periods.max(1) as f64; + for i in 0..periods.max(1) { + let f = i as f64; + wave(&mut v, x0 + (x1 - x0) * f / n, x0 + (x1 - x0) * (f + 1.0) / n, y, k); + } + v + }; + Spec::Fill(guide(top_x, a), guide(bot_x, 1.0 - a)) +} + +fn spec(name: &str, a: &Adj) -> Option> { + use Seg::*; + let fill = |t: Vec, b: Vec| vec![Spec::Fill(t, b)]; + let top = || line(&[(0.0, 0.0), (1.0, 0.0)]); + let bottom = || line(&[(0.0, 1.0), (1.0, 1.0)]); + Some(match name { + "textPlain" => { + let d = 2.0 * (a.frac(0, 0.3, 0.7) - 0.5); + if d < 0.0 { + fill(line(&[(-d, 0.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0 + d, 1.0)])) + } else { + fill(line(&[(0.0, 0.0), (1.0 - d, 0.0)]), line(&[(d, 1.0), (1.0, 1.0)])) + } + } + "textStop" => { + let y = a.frac(0, 0.14286, 0.5); + let t = 1.0 / 3.0; + fill(line(&[(0.0, y), (t, 0.0), (1.0 - t, 0.0), (1.0, y)]), line(&[(0.0, 1.0 - y), (t, 1.0), (1.0 - t, 1.0), (1.0, 1.0 - y)])) + } + "textTriangle" => { + let y = a.frac(0, 0.0, 1.0); + fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), bottom()) + } + "textTriangleInverted" => { + let y = a.frac(0, 0.0, 1.0); + fill(top(), line(&[(0.0, y), (0.5, 1.0), (1.0, y)])) + } + "textChevron" => { + let y = a.frac(0, 0.0, 0.5); + fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (0.5, 1.0 - y), (1.0, 1.0)])) + } + "textChevronInverted" => { + let y = a.frac(0, 0.5, 1.0); + fill(line(&[(0.0, 0.0), (0.5, 1.0 - y), (1.0, 0.0)]), line(&[(0.0, y), (0.5, 1.0), (1.0, y)])) + } + "textFadeRight" => { + let y = a.frac(0, 0.0, 0.49999); + fill(line(&[(0.0, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)])) + } + "textFadeLeft" => { + let y = a.frac(0, 0.0, 0.49999); + fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0 - y), (1.0, 1.0)])) + } + "textFadeUp" => { + let x = a.frac(0, 0.0, 0.49999); + fill(line(&[(x, 0.0), (1.0 - x, 0.0)]), bottom()) + } + "textFadeDown" => { + let x = a.frac(0, 0.0, 0.49999); + fill(top(), line(&[(x, 1.0), (1.0 - x, 1.0)])) + } + "textSlantUp" => { + let y = a.frac(0, 0.44444, 1.0); + fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)])) + } + "textSlantDown" => { + let y = a.frac(0, 0.0, 0.55556); + fill(line(&[(0.0, 0.0), (1.0, 1.0 - y)]), line(&[(0.0, y), (1.0, 1.0)])) + } + "textCascadeUp" => { + let y = a.frac(0, 0.0, 1.0); + fill(line(&[(0.0, (1.0 - y) / 4.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, y)])) + } + "textCascadeDown" => { + let y = a.frac(0, 0.0, 1.0); + fill(line(&[(0.0, 0.0), (1.0, (1.0 - y) / 4.0)]), line(&[(0.0, y), (1.0, 1.0)])) + } + "textInflate" => { + let y = a.frac(0, 0.0, 0.2); + fill(quad(y, -y), quad(1.0 - y, 1.0 + y)) + } + "textDeflate" => { + let y = a.frac(0, 0.0, 0.375); + fill(quad(0.0, 2.0 * y), quad(1.0, 1.0 - 2.0 * y)) + } + "textInflateBottom" => { + let y = a.frac(0, 0.6, 1.0); + fill(top(), quad(y, 1.0)) + } + "textDeflateBottom" => { + let y = a.frac(0, 0.0625, 1.0); + fill(top(), quad(1.0, 2.0 * y - 1.0)) + } + "textInflateTop" => { + let y = a.frac(0, 0.0, 0.5); + fill(quad(y, 0.0), bottom()) + } + "textDeflateTop" => { + let y = a.frac(0, 0.0, 0.9375); + fill(quad(0.0, 2.0 * y), bottom()) + } + "textDeflateInflate" => { + // The bands are split by a curve from mid-height up to `y`, with a gap of a tenth of + // a band's height. + let y = a.frac(0, 0.05, 0.95); + let g = 0.05; + vec![Spec::Fill(top(), quad(0.5 - g, 2.0 * y - 0.5 - g)), Spec::Fill(quad(0.5 + g, 2.0 * y - 0.5 + g), bottom())] + } + "textDeflateInflateDeflate" => { + let y = a.frac(0, 0.01, 1.0 / 3.0); + let g = 1.0 / 30.0; + let (t1, t2) = (1.0 / 3.0, 2.0 / 3.0); + let (c1, c2) = (2.0 * y - t1, 2.0 * (1.0 - y) - t2); + vec![ + Spec::Fill(top(), quad(t1 - g, c1 - g)), + Spec::Fill(quad(t1 + g, c1 + g), quad(t2 - g, c2 - g)), + Spec::Fill(quad(t2 + g, c2 + g), bottom()), + ] + } + "textCurveUp" | "textCurveDown" => { + let y = a.frac(0, 0.0, 0.56818); + let b = vec![M(0.0, 1.0 - 3.0 * y / 8.0), C(1.0 / 3.0, 1.0 + y / 8.0, 2.0 / 3.0, 1.0 + y / 8.0, 1.0, 1.0 - 3.0 * y / 8.0)]; + let t = if name == "textCurveUp" { + vec![M(0.0, y), C(1.0 / 3.0, 1.25 * y, 2.0 / 3.0, 0.75 * y, 1.0, 0.0)] + } else { + vec![M(0.0, 0.0), C(1.0 / 3.0, 0.75 * y, 2.0 / 3.0, 1.25 * y, 1.0, y)] + }; + fill(t, b) + } + "textCanUp" => { + let r = 1.0 - a.frac(0, 0.5, 1.0); + fill(vec![A(0.5, r, 0.5, r, 180.0, 180.0)], vec![A(0.5, 1.0, 0.5, r, 180.0, 180.0)]) + } + "textCanDown" => { + let r = a.frac(0, 0.0, 0.5); + fill(vec![A(0.5, 0.0, 0.5, r, 180.0, -180.0)], vec![A(0.5, 1.0 - r, 0.5, r, 180.0, -180.0)]) + } + "textRingInside" | "textRingOutside" => { + let h = a.frac(0, 0.5, 0.99); + let sweep = if name == "textRingInside" { 360.0 } else { -360.0 }; + fill(vec![A(0.5, h / 2.0, 0.5, h / 2.0, 180.0, sweep)], vec![A(0.5, 1.0 - h / 2.0, 0.5, h / 2.0, 180.0, sweep)]) + } + "textWave1" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, true)], + "textWave2" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, false)], + "textDoubleWave1" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, true)], + "textWave4" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, false)], + "textArchUp" => { + let (s, w) = arch_up(a.angle(0)); + vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)] + } + "textArchDown" => { + let (s, w) = arch_down(a.angle(0)); + vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::First)] + } + "textCircle" => { + let (s, w) = circle(a.angle(0)); + vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)] + } + "textButton" => { + let t = a.angle(0); + let (s, w) = arch_up(t); + let half = 0.5 * t.to_radians().cos().abs(); + vec![ + Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last), + Spec::Follow(line(&[(0.5 - half, 0.5), (0.5 + half, 0.5)]), LineAnchor::Middle), + Spec::Follow(ellipse(1.0, 360.0 - s, -w), LineAnchor::First), + ] + } + "textArchUpPour" => { + let (s, w) = arch_up(a.angle(0)); + let r = a.frac(1, 0.0, 0.99); + fill(ellipse(1.0, s, w), ellipse(r, s, w)) + } + "textArchDownPour" => { + let (s, w) = arch_down(a.angle(0)); + let r = a.frac(1, 0.0, 0.99); + fill(ellipse(r, s, w), ellipse(1.0, s, w)) + } + "textCirclePour" => { + let (s, w) = circle(a.angle(0)); + let r = a.frac(1, 0.0, 0.99); + fill(ellipse(1.0, s, w), ellipse(r, s, w)) + } + "textButtonPour" => { + let (s, w) = arch_up(a.angle(0)); + let r = a.frac(1, 0.0, 0.99); + // The middle band runs between chords of the inner ellipse halfway to its top and + // bottom. + let (dy, half) = (0.25 * r, 0.25 * r * 3f64.sqrt()); + vec![ + Spec::Fill(ellipse(1.0, s, w), ellipse(r, s, w)), + Spec::Fill(line(&[(0.5 - half, 0.5 - dy), (0.5 + half, 0.5 - dy)]), line(&[(0.5 - half, 0.5 + dy), (0.5 + half, 0.5 + dy)])), + Spec::Fill(ellipse(r, 360.0 - s, -w), ellipse(1.0, 360.0 - s, -w)), + ] + } + _ => return None, + }) +} + +/// Build a guide from unit-space segments mapped onto `r`. +fn guide(segs: &[Seg], r: Rect) -> Guide { + let mut p = BezPath::new(); + let mut started = false; + let mut closed = false; + for s in segs { + match *s { + Seg::M(x, y) => { + p.move_to((x, y)); + started = true; + } + Seg::L(x, y) => { + if started { + p.line_to((x, y)) + } else { + p.move_to((x, y)) + } + started = true; + } + Seg::Q(cx, cy, x, y) => { + if started { + p.quad_to((cx, cy), (x, y)); + } + } + Seg::C(x1, y1, x2, y2, x, y) => { + if started { + p.curve_to((x1, y1), (x2, y2), (x, y)); + } + } + Seg::A(cx, cy, rx, ry, start, sweep) => { + let arc = Arc::new((cx, cy), (rx.max(0.0), ry.max(0.0)), start.to_radians(), sweep.to_radians(), 0.0); + let s0 = Point::new(cx + rx * start.to_radians().cos(), cy + ry * start.to_radians().sin()); + if started { + p.line_to(s0); + } else { + p.move_to(s0); + } + started = true; + for el in arc.append_iter(1e-5) { + p.push(el); + } + closed = sweep.abs() >= 359.999; + } + } + } + let m = kurbo::Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]); + p.apply_affine(m); + Guide::from_path(&p, closed) +} + +/// The warp `name` with adjust values `adj` (raw guide values; missing ones take the defaults) +/// over text rectangle `rect`. `None` for unknown presets, `textNoShape` or a non-finite rect. +pub fn warp(name: &str, adj: &[f64], rect: Rect) -> Option { + if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) { + return None; + } + let r = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1)); + let a = Adj { raw: adj, defaults: adjust_defaults(name) }; + let specs = spec(name, &a)?; + let bands = specs + .into_iter() + .map(|s| match s { + Spec::Fill(t, b) => Band::Fill { top: guide(&t, r), bottom: guide(&b, r) }, + Spec::Follow(p, anchor) => Band::Follow { path: guide(&p, r), anchor }, + }) + .collect(); + Some(Warp { bands }) +} + +/// The band line `line` of `lines` goes to when there are `bands` bands: the first ⌈n/k⌉ lines +/// share the first band, and so on. +pub fn band_of_line(line: usize, lines: usize, bands: usize) -> usize { + if bands <= 1 || lines == 0 { + return 0; + } + // Band b holds lines ceil(b·n/k) .. ceil((b+1)·n/k). + (0..bands).rev().find(|&b| (b * lines).div_ceil(bands) <= line).unwrap_or(0) +} + +/// One laid-out line as the warp sees it (layout coordinates, points). +#[derive(Clone, Copy, Debug)] +pub struct WarpLine { + /// Ink box of the line's glyphs; `None` for a line without ink. + pub ink: Option, + /// Advance extent of the line (left, right). + pub extent: (f64, f64), + /// Reference height for follow bands: the y that sits on the guide. + pub ref_y: f64, +} + +#[derive(Clone, Copy, Debug)] +enum LineMap { + Fill { + band: usize, + cx: f64, + k: f64, + len: f64, + y0: f64, + y1: f64, + }, + /// `guide`: the line's own parallel guide in [`Mapper::offsets`], if it is off the band's. + Follow { + band: usize, + guide: Option, + e0: f64, + s0: f64, + k: f64, + ref_y: f64, + }, +} + +/// Maps points of laid-out lines onto a warp. +#[derive(Clone, Debug)] +pub struct Mapper<'a> { + warp: &'a Warp, + lines: Vec, + offsets: Vec, + zoom: f64, +} + +impl Warp { + /// Plan the mapping of `lines`, laid out in the inner text box `inner` (the line's place in + /// it gives its alignment along follow guides; the insets themselves don't shorten a guide). + pub fn mapper(&self, lines: &[WarpLine], inner: Rect) -> Mapper<'_> { + let nb = self.bands.len().max(1); + let n = lines.len(); + let band_of: Vec = (0..n).map(|i| band_of_line(i, n, nb).min(nb - 1)).collect(); + // Ink of the whole text, for the shared horizontal centre and scale of fill bands. + let ink_all = lines.iter().filter_map(|l| l.ink).reduce(|a, b| a.union(b)); + let cx = ink_all.map(|r| r.center().x).unwrap_or(0.0); + let mut k_fill = f64::INFINITY; + let mut zoom: f64 = 1.0; + let mut band_y: Vec> = vec![None; nb]; + for (i, l) in lines.iter().enumerate() { + let Some(r) = l.ink else { continue }; + let b = band_of.get(i).copied().unwrap_or(0); + if let Some(slot) = band_y.get_mut(b) { + *slot = Some(match *slot { + Some((y0, y1)) => (y0.min(r.y0), y1.max(r.y1)), + None => (r.y0, r.y1), + }); + } + } + // Every fill band maps a box as tall as the tallest band's ink, centred on its own ink, so + // a band with fewer lines keeps them at the same size, centred between its guides. + let tallest = band_y.iter().flatten().map(|(y0, y1)| y1 - y0).fold(0.0, f64::max); + let band_y: Vec> = + band_y.iter().map(|r| r.map(|(y0, y1)| ((y0 + y1 - tallest) / 2.0, (y0 + y1 + tallest) / 2.0))).collect(); + for (b, band) in self.bands.iter().enumerate() { + if let Band::Fill { top, bottom } = band { + let half = lines + .iter() + .enumerate() + .filter(|(i, _)| band_of.get(*i) == Some(&b)) + .filter_map(|(_, l)| l.ink) + .map(|r| (r.x0 - cx).abs().max((r.x1 - cx).abs())) + .fold(0.0, f64::max); + if half > 1e-9 { + k_fill = k_fill.min(band.length() / (2.0 * half)); + } + if let Some(Some((y0, y1))) = band_y.get(b) { + let thick = [0.0, 0.25, 0.5, 0.75, 1.0].iter().map(|&u| (top.at_frac(u) - bottom.at_frac(u)).hypot()).fold(0.0, f64::max); + if y1 - y0 > 1e-9 { + zoom = zoom.max(thick / (y1 - y0)); + } + } + } + } + if !k_fill.is_finite() { + k_fill = 1.0; + } + zoom = zoom.max(k_fill); + let mut maps = Vec::with_capacity(n); + let mut offsets = Vec::new(); + for (i, l) in lines.iter().enumerate() { + let b = band_of.get(i).copied().unwrap_or(0); + let in_band = |j: &usize| band_of.get(*j) == Some(&b); + match self.bands.get(b) { + Some(Band::Fill { .. }) | None => { + let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0)); + let len = self.bands.get(b).map(Band::length).unwrap_or(0.0); + maps.push(LineMap::Fill { band: b, cx, k: k_fill, len, y0, y1 }); + } + Some(Band::Follow { path, anchor }) => { + let len = path.length(); + let widest = (0..n).filter(in_band).filter_map(|j| lines.get(j)).map(|l| l.extent.1 - l.extent.0).fold(0.0, f64::max); + let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 }; + let members: Vec<&WarpLine> = (0..n).filter(in_band).filter_map(|j| lines.get(j)).collect(); + let ref_y = match anchor { + LineAnchor::Last => members.last().map(|l| l.ref_y), + LineAnchor::First => members.first().map(|l| l.ref_y), + LineAnchor::Middle => match (members.first(), members.last()) { + (Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0), + _ => None, + }, + } + .unwrap_or(l.ref_y); + // Lines off the guide run on a parallel curve at their natural length. + let d = (ref_y - l.ref_y) * k; + let (guide, len) = if d.abs() > 1e-6 && d.is_finite() && offsets.len() < 4096 { + let g = path.offset(d); + let len = g.length(); + offsets.push(g); + (Some(offsets.len() - 1), len) + } else { + (None, len) + }; + let (e0, e1) = l.extent; + let w = (e1 - e0).max(0.0) * k; + let (gl, gr) = ((e0 - inner.x0).max(0.0), (inner.x1 - e1).max(0.0)); + let f = if gl + gr > 1e-9 { gl / (gl + gr) } else { 0.5 }; + let s0 = f * (len - w); + maps.push(LineMap::Follow { band: b, guide, e0, s0, k, ref_y: l.ref_y }); + } + } + } + Mapper { warp: self, lines: maps, offsets, zoom: if zoom.is_finite() { zoom.clamp(1.0, 1.0e4) } else { 1.0 } } + } +} + +impl Mapper<'_> { + /// How much the warp magnifies text at most (for flattening tolerances), ≥ 1. + pub fn zoom(&self) -> f64 { + self.zoom + } + + /// Map point `p` of line `line` (layout coordinates) onto the warp. + pub fn map(&self, line: usize, p: Point) -> Point { + let out = match self.lines.get(line) { + Some(&LineMap::Fill { band, cx, k, len, y0, y1 }) => { + let Some(Band::Fill { top, bottom }) = self.warp.bands.get(band) else { return p }; + let u = if len > 1e-9 { 0.5 + (p.x - cx) * k / len } else { 0.5 }; + let v = if y1 - y0 > 1e-9 { (p.y - y0) / (y1 - y0) } else { 0.5 }; + let (t, b) = (top.at_frac(u), bottom.at_frac(u)); + t.lerp(b, v) + } + Some(&LineMap::Follow { band, guide, e0, s0, k, ref_y }) => { + let path = match (guide.and_then(|g| self.offsets.get(g)), self.warp.bands.get(band)) { + (Some(g), _) => g, + (None, Some(Band::Follow { path, .. })) => path, + _ => return p, + }; + let s = s0 + (p.x - e0) * k; + let d = (ref_y - p.y) * k; + let (q, t) = path.at(s); + q + Vec2::new(t.y, -t.x) * d + } + None => p, + }; + if out.x.is_finite() && out.y.is_finite() { out } else { p } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/geom/src/warp/tests.rs b/crates/geom/src/warp/tests.rs new file mode 100644 index 00000000..2d18b13a --- /dev/null +++ b/crates/geom/src/warp/tests.rs @@ -0,0 +1,309 @@ +use super::*; + +const ALL: &[&str] = &[ + "textPlain", + "textStop", + "textTriangle", + "textTriangleInverted", + "textChevron", + "textChevronInverted", + "textRingInside", + "textRingOutside", + "textArchUp", + "textArchDown", + "textCircle", + "textButton", + "textArchUpPour", + "textArchDownPour", + "textCirclePour", + "textButtonPour", + "textCurveUp", + "textCurveDown", + "textCanUp", + "textCanDown", + "textWave1", + "textWave2", + "textDoubleWave1", + "textWave4", + "textInflate", + "textDeflate", + "textInflateBottom", + "textDeflateBottom", + "textInflateTop", + "textDeflateTop", + "textDeflateInflate", + "textDeflateInflateDeflate", + "textFadeRight", + "textFadeLeft", + "textFadeUp", + "textFadeDown", + "textSlantUp", + "textSlantDown", + "textCascadeUp", + "textCascadeDown", +]; + +const W: f64 = 420.0; +const H: f64 = 200.0; + +fn rect() -> Rect { + Rect::new(0.0, 0.0, W, H) +} + +/// One line whose ink box is `ink`. +fn one_line(ink: Rect) -> Vec { + vec![WarpLine { ink: Some(ink), extent: (ink.x0, ink.x1), ref_y: ink.y1 }] +} + +/// Where the unit-square point (u, v) of a single line's ink box lands, in unit coordinates. +fn land(name: &str, adj: &[f64], u: f64, v: f64) -> (f64, f64) { + let w = warp(name, adj, rect()).unwrap(); + let ink = Rect::new(100.0, 50.0, 300.0, 90.0); + let lines = one_line(ink); + let m = w.mapper(&lines, rect().inset(-5.0)); + let p = m.map(0, Point::new(ink.x0 + u * ink.width(), ink.y0 + v * ink.height())); + (p.x / W, p.y / H) +} + +fn close(a: (f64, f64), b: (f64, f64), tol: f64) -> bool { + (a.0 - b.0).abs() <= tol && (a.1 - b.1).abs() <= tol +} + +#[track_caller] +fn corners(name: &str, adj: &[f64], tl: (f64, f64), tr: (f64, f64), bl: (f64, f64), br: (f64, f64)) { + for ((u, v), want) in [((0.0, 0.0), tl), ((1.0, 0.0), tr), ((0.0, 1.0), bl), ((1.0, 1.0), br)] { + let got = land(name, adj, u, v); + assert!(close(got, want, 0.004), "{name} corner ({u},{v}): got {got:?}, want {want:?}"); + } +} + +#[test] +fn every_preset_builds_finite_guides() { + for name in ALL { + assert!(is_supported(name), "{name}"); + let w = warp(name, &[], rect()).unwrap_or_else(|| panic!("{name}")); + assert!(!w.bands.is_empty(), "{name}"); + for b in &w.bands { + let l = b.length(); + assert!(l.is_finite() && l > 10.0, "{name}: band length {l}"); + } + assert_eq!(adjust_count(name), adjust_defaults(name).len()); + } + assert!(warp("textNoShape", &[], rect()).is_none()); + assert!(warp("bogus", &[], rect()).is_none()); + assert!(!is_supported("textNoShape")); +} + +#[test] +fn straight_presets_put_the_ink_corners_on_their_guides() { + corners("textPlain", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)); + // A smaller adjust shears the text: the top edge starts 40 % in, the bottom edge ends 60 % in. + corners("textPlain", &[30_000.0], (0.4, 0.0), (1.0, 0.0), (0.0, 1.0), (0.6, 1.0)); + corners("textStop", &[], (0.0, 0.25), (1.0, 0.25), (0.0, 0.75), (1.0, 0.75)); + corners("textTriangle", &[], (0.0, 0.5), (1.0, 0.5), (0.0, 1.0), (1.0, 1.0)); + corners("textTriangleInverted", &[25_000.0], (0.0, 0.0), (1.0, 0.0), (0.0, 0.25), (1.0, 0.25)); + corners("textChevron", &[], (0.0, 0.25), (1.0, 0.25), (0.0, 1.0), (1.0, 1.0)); + corners("textChevronInverted", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 0.75), (1.0, 0.75)); + corners("textFadeRight", &[], (0.0, 0.0), (1.0, 0.33333), (0.0, 1.0), (1.0, 0.66667)); + corners("textFadeLeft", &[], (0.0, 0.33333), (1.0, 0.0), (0.0, 0.66667), (1.0, 1.0)); + corners("textFadeUp", &[], (0.33333, 0.0), (0.66667, 0.0), (0.0, 1.0), (1.0, 1.0)); + corners("textFadeDown", &[], (0.0, 0.0), (1.0, 0.0), (0.33333, 1.0), (0.66667, 1.0)); + corners("textSlantUp", &[], (0.0, 0.55556), (1.0, 0.0), (0.0, 1.0), (1.0, 0.44444)); + corners("textSlantDown", &[], (0.0, 0.0), (1.0, 0.55556), (0.0, 0.44444), (1.0, 1.0)); + corners("textCascadeUp", &[], (0.0, 0.13889), (1.0, 0.0), (0.0, 1.0), (1.0, 0.44444)); + corners("textCascadeDown", &[], (0.0, 0.0), (1.0, 0.13889), (0.0, 0.44444), (1.0, 1.0)); +} + +#[test] +fn curved_presets_bend_where_powerpoint_bends_them() { + corners("textInflate", &[], (0.0, 0.1875), (1.0, 0.1875), (0.0, 0.8125), (1.0, 0.8125)); + assert!(close(land("textInflate", &[], 0.5, 0.0), (0.5, 0.0), 0.004)); + assert!(close(land("textInflate", &[], 0.5, 1.0), (0.5, 1.0), 0.004)); + corners("textDeflate", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)); + assert!(close(land("textDeflate", &[], 0.5, 0.0), (0.5, 0.1875), 0.004)); + corners("textInflateBottom", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 0.6), (1.0, 0.6)); + assert!(close(land("textInflateBottom", &[], 0.5, 1.0), (0.5, 0.8), 0.004)); + assert!(close(land("textDeflateBottom", &[], 0.5, 1.0), (0.5, 0.5), 0.004)); + assert!(close(land("textInflateTop", &[], 0.5, 0.0), (0.5, 0.2), 0.004)); + assert!(close(land("textDeflateTop", &[], 0.5, 0.0), (0.5, 0.5), 0.004)); + // Cans: half ellipses. + corners("textCanUp", &[], (0.0, 0.15), (1.0, 0.15), (0.0, 1.0), (1.0, 1.0)); + assert!(close(land("textCanUp", &[], 0.5, 0.0), (0.5, 0.0), 0.004)); + assert!(close(land("textCanUp", &[], 0.5, 1.0), (0.5, 0.85), 0.004)); + assert!(close(land("textCanDown", &[], 0.5, 0.0), (0.5, 0.14286), 0.004)); + // Curves and waves. + let a = 0.45977; + corners("textCurveUp", &[], (0.0, a), (1.0, 0.0), (0.0, 1.0 - 3.0 * a / 8.0), (1.0, 1.0 - 3.0 * a / 8.0)); + assert!(close(land("textCurveUp", &[], 0.5, 1.0), (0.5, 1.0), 0.004)); + corners("textCurveDown", &[], (0.0, 0.0), (1.0, a), (0.0, 1.0 - 3.0 * a / 8.0), (1.0, 1.0 - 3.0 * a / 8.0)); + corners("textWave1", &[], (0.0, 0.125), (1.0, 0.125), (0.0, 0.875), (1.0, 0.875)); + // Wave 1 rises first: its top is near the box top a quarter of the way along. + assert!(land("textWave1", &[], 0.25, 0.0).1 < 0.02); + assert!(land("textWave2", &[], 0.25, 0.0).1 > 0.23); + // A horizontal shift moves the bottom guide right. + corners("textWave1", &[12_500.0, 5_000.0], (0.0, 0.125), (0.9, 0.125), (0.1, 0.875), (1.0, 0.875)); + assert!(land("textDoubleWave1", &[], 0.125, 0.0).1 < 0.02); + assert!(land("textWave4", &[], 0.125, 0.0).1 > 0.1); +} + +#[test] +fn arc_presets_follow_ellipses() { + // Pour arches: the outer arc from the left end over the top; the inner one is scaled by adj2. + corners("textArchUpPour", &[], (0.0, 0.5), (1.0, 0.5), (0.25, 0.5), (0.75, 0.5)); + assert!(close(land("textArchUpPour", &[], 0.5, 0.0), (0.5, 0.0), 0.004)); + assert!(close(land("textArchUpPour", &[], 0.5, 1.0), (0.5, 0.25), 0.004)); + assert!(close(land("textArchUpPour", &[10_800_000.0, 30_000.0], 0.5, 1.0), (0.5, 0.35), 0.004)); + assert!(close(land("textArchDownPour", &[], 0.5, 0.0), (0.5, 0.625), 0.004)); + assert!(close(land("textArchDownPour", &[], 0.5, 1.0), (0.5, 1.0), 0.004)); + // Circle pour: once round from the left; the middle of the text is at the right. + assert!(close(land("textCirclePour", &[], 0.5, 0.0), (1.0, 0.5), 0.004)); + // Rings: the text goes round two stacked ellipses. + assert!(close(land("textRingInside", &[], 0.0, 0.0), (0.0, 0.3), 0.004)); + assert!(close(land("textRingInside", &[], 0.0, 1.0), (0.0, 0.7), 0.004)); + assert!(close(land("textRingInside", &[], 0.25, 0.0), (0.5, 0.0), 0.01)); + assert!(close(land("textRingOutside", &[], 0.25, 1.0), (0.5, 1.0), 0.01)); +} + +#[test] +fn follow_presets_keep_text_size_and_stack_lines_outwards() { + let w = warp("textArchUp", &[], rect()).unwrap(); + // A 40-wide centred line whose reference height is 80. + let line = WarpLine { ink: Some(Rect::new(190.0, 50.0, 230.0, 88.0)), extent: (190.0, 230.0), ref_y: 80.0 }; + let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6); + let m = w.mapper(&[line], inner); + // The middle of the reference line lands on the top of the ellipse; 10 pt above it lands + // 10 pt above the top. + let p = m.map(0, Point::new(210.0, 80.0)); + assert!((p.x - 210.0).abs() < 0.05 && p.y.abs() < 0.05, "{p:?}"); + let q = m.map(0, Point::new(210.0, 70.0)); + assert!((q.x - 210.0).abs() < 0.15 && (q.y + 10.0).abs() < 0.05, "{q:?}"); + // Natural size: 40 pt of text spans about 40 pt of arc. + let (a, b) = (m.map(0, Point::new(190.0, 80.0)), m.map(0, Point::new(230.0, 80.0))); + assert!(((b - a).hypot() - 40.0).abs() < 0.5); + // Left-aligned text starts at the very start of the arc: insets don't shorten it. + let left = WarpLine { ink: Some(Rect::new(7.2, 50.0, 47.2, 88.0)), extent: (7.2, 47.2), ref_y: 80.0 }; + let m = w.mapper(&[left], inner); + let p = m.map(0, Point::new(7.2, 80.0)); + assert!(p.x.abs() < 0.01 && (p.y - 100.0).abs() < 0.01, "{p:?}"); + // Two lines on an arch up: the last sits on the arc, the first stacks outside it. + let l1 = WarpLine { ink: Some(Rect::new(190.0, 10.0, 230.0, 40.0)), extent: (190.0, 230.0), ref_y: 34.0 }; + let l2 = WarpLine { ink: Some(Rect::new(190.0, 50.0, 230.0, 80.0)), extent: (190.0, 230.0), ref_y: 74.0 }; + let m = w.mapper(&[l1, l2], inner); + assert!(m.map(1, Point::new(210.0, 74.0)).y.abs() < 0.05); + assert!((m.map(0, Point::new(210.0, 34.0)).y + 40.0).abs() < 0.05); + // Arch down: the first line sits on the bottom of the ellipse. + let w = warp("textArchDown", &[], rect()).unwrap(); + let m = w.mapper(&[l1, l2], inner); + assert!((m.map(0, Point::new(210.0, 34.0)).y - H).abs() < 0.05); + assert!((m.map(1, Point::new(210.0, 74.0)).y - H - 40.0).abs() < 0.05); + // Text longer than the arc is shrunk to fit it. + let w = warp("textArchUp", &[], rect()).unwrap(); + let long = WarpLine { ink: Some(Rect::new(-500.0, 50.0, 900.0, 88.0)), extent: (-500.0, 900.0), ref_y: 80.0 }; + let m = w.mapper(&[long], inner); + let (a, b) = (m.map(0, Point::new(-500.0, 80.0)), m.map(0, Point::new(900.0, 80.0))); + assert!(a.x < 5.0 && b.x > W - 5.0 && (a.y - 100.0).abs() < 10.0 && (b.y - 100.0).abs() < 10.0, "{a:?} {b:?}"); +} + +#[test] +fn lines_share_bands_in_order() { + assert_eq!((0..2).map(|i| band_of_line(i, 2, 2)).collect::>(), [0, 1]); + assert_eq!((0..3).map(|i| band_of_line(i, 3, 2)).collect::>(), [0, 0, 1]); + assert_eq!((0..5).map(|i| band_of_line(i, 5, 2)).collect::>(), [0, 0, 0, 1, 1]); + assert_eq!((0..5).map(|i| band_of_line(i, 5, 3)).collect::>(), [0, 0, 1, 1, 2]); + assert_eq!((0..4).map(|i| band_of_line(i, 4, 3)).collect::>(), [0, 0, 1, 2]); + assert_eq!((0..2).map(|i| band_of_line(i, 2, 3)).collect::>(), [0, 1]); + assert_eq!(band_of_line(0, 1, 3), 0); + assert_eq!(band_of_line(7, 9, 1), 0); + assert_eq!(band_of_line(0, 0, 3), 0); +} + +#[test] +fn deflate_inflate_maps_each_band_separately() { + let w = warp("textDeflateInflate", &[], rect()).unwrap(); + assert_eq!(w.bands.len(), 2); + let l1 = WarpLine { ink: Some(Rect::new(100.0, 10.0, 300.0, 40.0)), extent: (100.0, 300.0), ref_y: 34.0 }; + let l2 = WarpLine { ink: Some(Rect::new(100.0, 50.0, 300.0, 80.0)), extent: (100.0, 300.0), ref_y: 74.0 }; + let m = w.mapper(&[l1, l2], rect()); + // Band 1 ends at 45 % height on the sides, band 2 starts at 55 %. + assert!((m.map(0, Point::new(100.0, 40.0)).y / H - 0.45).abs() < 0.004); + assert!((m.map(1, Point::new(100.0, 50.0)).y / H - 0.55).abs() < 0.004); + assert!((m.map(1, Point::new(300.0, 80.0)).y / H - 1.0).abs() < 0.004); + // A band with fewer lines keeps them at the size of the fuller band, centred in its band: + // two lines (ink 10..60) in band 1, one line (ink 70..80) in band 2 of three. + let a = WarpLine { ink: Some(Rect::new(100.0, 10.0, 300.0, 30.0)), extent: (100.0, 300.0), ref_y: 28.0 }; + let b = WarpLine { ink: Some(Rect::new(100.0, 40.0, 300.0, 60.0)), extent: (100.0, 300.0), ref_y: 58.0 }; + let c = WarpLine { ink: Some(Rect::new(100.0, 70.0, 300.0, 80.0)), extent: (100.0, 300.0), ref_y: 78.0 }; + let m = w.mapper(&[a, b, c], rect()); + let (top, bot) = (m.map(2, Point::new(200.0, 70.0)).y / H, m.map(2, Point::new(200.0, 80.0)).y / H); + // Band 2 runs from 0.4 to 1 at the centre; the line takes the middle fifth of it. + assert!((top - 0.64).abs() < 0.005 && (bot - 0.76).abs() < 0.005, "{top} {bot}"); + // A shorter first line keeps its share of the width. + let short = WarpLine { ink: Some(Rect::new(150.0, 10.0, 250.0, 40.0)), extent: (150.0, 250.0), ref_y: 34.0 }; + let m = w.mapper(&[short, l2], rect()); + let p = m.map(0, Point::new(150.0, 10.0)); + assert!((p.x / W - 0.25).abs() < 0.01, "{p:?}"); +} + +#[test] +fn guides_extrapolate_and_wrap() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((10.0, 0.0)); + let g = Guide::from_path(&p, false); + assert!((g.length() - 10.0).abs() < 1e-9); + assert!((g.at(15.0).0.x - 15.0).abs() < 1e-9); + assert!((g.at(-5.0).0.x + 5.0).abs() < 1e-9); + assert!(g.at(f64::NAN).0.x.is_finite()); + let w = warp("textCircle", &[], rect()).unwrap(); + let Some(Band::Follow { path, .. }) = w.bands.first() else { panic!() }; + assert!(path.is_closed()); + let l = path.length(); + let (a, b) = (path.at(5.0).0, path.at(l + 5.0).0); + assert!((a - b).hypot() < 1e-6); + let empty = Guide::default(); + assert_eq!(empty.length(), 0.0); + assert!(empty.at(3.0).0.x.is_finite()); +} + +#[test] +fn hostile_adjusts_and_boxes_stay_finite() { + let adjs: &[&[f64]] = + &[&[], &[f64::NAN, f64::INFINITY], &[f64::NEG_INFINITY, f64::NAN], &[1e300, -1e300], &[-1e9, 1e9], &[0.0, 0.0], &[21_599_999.0, 100_000.0]]; + let rects = [ + Rect::new(0.0, 0.0, 0.0, 0.0), + Rect::new(10.0, 10.0, 10.0, 200.0), + Rect::new(0.0, 0.0, 300.0, 0.0), + Rect::new(100.0, 100.0, -50.0, -20.0), + Rect::new(0.0, 0.0, 1e7, 1e7), + ]; + let lines = [ + WarpLine { ink: Some(Rect::new(0.0, 0.0, 0.0, 0.0)), extent: (0.0, 0.0), ref_y: 0.0 }, + WarpLine { ink: None, extent: (5.0, 5.0), ref_y: 3.0 }, + WarpLine { ink: Some(Rect::new(-1e6, -5.0, 1e6, 5.0)), extent: (-1e6, 1e6), ref_y: 4.0 }, + ]; + for name in ALL { + for adj in adjs { + for r in rects { + let Some(w) = warp(name, adj, r) else { panic!("{name} {adj:?} {r:?}") }; + for b in &w.bands { + assert!(b.length().is_finite()); + } + let m = w.mapper(&lines, r); + assert!(m.zoom().is_finite() && m.zoom() >= 1.0); + let probes = [ + (0, Point::ZERO), + (1, Point::new(5.0, 3.0)), + (2, Point::new(1e6, -5.0)), + (2, Point::new(f64::NAN, 1.0)), + (9, Point::new(1.0, 1.0)), + ]; + for (i, p) in probes { + let q = m.map(i, p); + assert!(q.x.is_finite() || !p.x.is_finite(), "{name} {adj:?} {r:?}: {q:?}"); + assert!(q.y.is_finite(), "{name} {adj:?} {r:?}: {q:?}"); + } + } + } + } + assert!(warp("textPlain", &[], Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none()); +} diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 3ea7f998..54eb4c87 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -223,6 +223,7 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps { columns: Some(1), col_spacing: Some(0.0), warp: None, + warp_adj: Vec::new(), rtl_col: Some(false), upright: Some(false), }); diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index feaff468..b8138a39 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -98,6 +98,10 @@ pub struct BodyProps { pub col_spacing: Option, /// WordArt transform (`prstTxWarp`), e.g. `textArchUp`. pub warp: Option, + /// The warp's adjust values (`prstTxWarp/avLst`: `adj` or `adj1`, `adj2`…, raw guide values); + /// empty = the preset's defaults. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub warp_adj: Vec, pub rtl_col: Option, /// Keep text upright when the shape rotates. pub upright: Option, @@ -119,6 +123,7 @@ impl BodyProps { self.col_spacing = self.col_spacing.or(p.col_spacing); if self.warp.is_none() { self.warp.clone_from(&p.warp); + self.warp_adj.clone_from(&p.warp_adj); } self.rtl_col = self.rtl_col.or(p.rtl_col); self.upright = self.upright.or(p.upright); diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 36f2a662..c902776d 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -321,13 +321,45 @@ pub fn body_pr(b: &El) -> BodyProps { } "spAutoFit" => p.autofit = Some(AutoFit::Shape), "noAutofit" => p.autofit = Some(AutoFit::None), - "prstTxWarp" => p.warp = e.attr("prst").filter(|w| *w != "textNoShape").map(String::from), + "prstTxWarp" => { + p.warp = e.attr("prst").filter(|w| *w != "textNoShape" && !w.is_empty()).map(String::from); + p.warp_adj = p.warp.as_deref().map(|w| warp_adjusts(w, e)).unwrap_or_default(); + } _ => {} } } p } +/// `prstTxWarp/avLst` guides `adj` / `adj1`… as raw values by index (empty when none is given); +/// slots the file leaves out keep the preset's default. +fn warp_adjusts(preset: &str, e: &El) -> Vec { + let mut adj: Vec = Vec::new(); + let Some(av) = e.child("avLst") else { return adj }; + let defaults = deckcraft_geom::warp::adjust_defaults(preset); + for gd in av.children_named("gd").take(16) { + let n = gd.attr("name").unwrap_or(""); + let idx = if n == "adj" { + 0 + } else if let Some(k) = n.strip_prefix("adj").and_then(|k| k.parse::().ok()).filter(|k| (1..=8).contains(k)) { + k - 1 + } else { + continue; + }; + let Some(v) = gd.attr("fmla").and_then(|f| f.trim().strip_prefix("val")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()) + else { + continue; + }; + while adj.len() <= idx { + adj.push(defaults.get(adj.len()).copied().unwrap_or(0.0)); + } + if let Some(slot) = adj.get_mut(idx) { + *slot = v.clamp(-1.0e9, 1.0e9); + } + } + adj +} + fn spacing(e: &El) -> Option { if let Some(p) = e.child("spcPct") { return Some(Spacing::Pct(pct(p, "val").unwrap_or(1.0).clamp(0.0, 100.0))); diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 23d33400..4c2e5d99 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -392,7 +392,19 @@ pub fn body_pr(w: &mut W, b: &BodyProps) { w.open("a:bodyPr", a); if let Some(p) = warp { w.open("a:prstTxWarp", A::new().a("prst", p)); - w.empty0("a:avLst"); + let adj: Vec<(usize, i64)> = + b.warp_adj.iter().take(8).enumerate().filter(|(_, v)| v.is_finite()).map(|(i, v)| (i, v.round().clamp(-2.0e9, 2.0e9) as i64)).collect(); + if adj.is_empty() { + w.empty0("a:avLst"); + } else { + let single = deckcraft_geom::warp::adjust_count(p) <= 1 && b.warp_adj.len() == 1; + w.open0("a:avLst"); + for (i, v) in adj { + let n = if single { "adj".to_string() } else { format!("adj{}", i + 1) }; + w.empty("a:gd", A::new().a("name", n).a("fmla", format!("val {v}"))); + } + w.close("a:avLst"); + } w.close("a:prstTxWarp"); } match b.autofit { diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 08fcbdc0..c9f33fe3 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -796,3 +796,64 @@ fn a_table_fill_round_trips() { matches!(&t.background, Some(Fill::Solid { color }) if matches!(color.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(0, 112, 192))) ); } + +#[test] +fn wordart_warps_and_their_adjusts_round_trip() { + let mut p = Presentation::default(); + let cases: [(&str, &str, Vec); 5] = [ + ("Arch pour", "textArchUpPour", vec![13_500_000.0, 30_000.0]), + ("Wave", "textWave1", vec![18_000.0, -5_000.0]), + ("Inflate", "textInflate", vec![30_000.0]), + ("Arch", "textArchUp", vec![]), + ("Ring", "textRingInside", vec![75_000.0]), + ]; + let mut shapes = vec![]; + for (i, (name, warp, adj)) in cases.iter().enumerate() { + let mut s = shape(&mut p, name, 10.0, 10.0 + 50.0 * i as f64, 300.0, 40.0); + let mut body = TextBody::from_text("WordArt"); + body.body.warp = Some((*warp).into()); + body.body.warp_adj = adj.clone(); + s.text = Some(body); + shapes.push(s); + } + Arc::make_mut(&mut p.slides[0]).shapes = shapes; + let q = round(&p); + for (name, warp, adj) in &cases { + let b = &by_name(&q.slides[0], name).text.as_ref().expect("text").body; + assert_eq!(b.warp.as_deref(), Some(*warp), "{name}"); + assert_eq!(&b.warp_adj, adj, "{name}"); + } + // The single-adjust preset is written with the guide name `adj`, two-adjust ones with + // `adj1`/`adj2`, and a preset with no adjusts given gets an empty list. + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/slide1.xml").expect("slide"), &mut xml).expect("read"); + assert!(xml.contains(r#""#), "{xml}"); + assert!(xml.contains(r#""#)); + assert!(xml.contains(r#""#)); + // A file that gives only `adj2` keeps the preset's default for `adj1`; hostile values are + // read as numbers or dropped. + let edited = xml + .replace(r#""#, "") + .replace(r#""#, r#""#); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..zip.len() { + let mut f = zip.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = vec![]; + std::io::Read::read_to_end(&mut f, &mut data).expect("read"); + if name == "ppt/slides/slide1.xml" { + data = edited.clone().into_bytes(); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start"); + std::io::Write::write_all(&mut out, &data).expect("write"); + } + let bytes = out.finish().expect("finish").into_inner(); + let q = deckcraft_pptx::import(&bytes).expect("import"); + let pour = &by_name(&q.slides[0], "Arch pour").text.as_ref().expect("text").body; + assert_eq!(pour.warp_adj, vec![10_800_000.0, 30_000.0]); + let inflate = &by_name(&q.slides[0], "Inflate").text.as_ref().expect("text").body; + assert_eq!(inflate.warp.as_deref(), Some("textInflate")); + assert!(inflate.warp_adj.is_empty()); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b4938893..4e8058ad 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -21,6 +21,7 @@ mod pathgrad; mod placed; mod ramp; mod table; +mod warp; use std::collections::HashMap; use std::sync::Arc; @@ -816,7 +817,10 @@ impl Renderer { let tr = geo.text_rect; if !body.is_empty() { let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false }); - draw_layout_with(ctx, &l, tr, m, &st.paras); + match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) { + Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w), + None => draw_layout_with(ctx, &l, tr, m, &st.paras), + } } else if f.opts.edit && s.ph.is_some() { let kind = s.ph_type().unwrap_or(PhType::Body); let prompt_body = prompt_body(rctx, s, kind); @@ -1146,6 +1150,13 @@ pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine { t } +/// The WordArt warp of `s`'s text over its text rectangle `tr`, if it has one DeckCraft draws. +pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option { + let bp = resolve::body(rctx, s); + let name = bp.warp.as_deref()?; + deckcraft_geom::warp::warp(name, &bp.warp_adj, tr) +} + /// Draw an already laid-out text block. pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) { draw_layout_with(ctx, l, tr, m, &[]); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 9a0c6fef..adcc4497 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -994,3 +994,84 @@ fn shadows_and_glows_spread_like_powerpoints() { assert!((60..170).contains(&red(&img, 15, 120)), "{}", red(&img, 15, 120)); assert!(red(&img, 10, 120) > 245); } + +/// A 400 × 200 text box at (100, 100) holding `text` at 40 pt, warped by `warp`. +fn wordart(warp: Option<&str>, text: &str) -> Presentation { + let mut t = TextBody::from_text(text); + t.paragraphs[0].runs[0].props.size = Some(40.0); + t.paragraphs[0].props.align = Some(deckcraft_model::text::Align::Center); + t.body.warp = warp.map(String::from); + deck_with(vec![Shape { id: ShapeId(700), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), text: Some(t), ..Default::default() }]) +} + +/// Ink box (x0, y0, x1, y1) of dark pixels, and the top ink row of each column. +fn ink(img: &Image) -> ((u32, u32, u32, u32), Vec>) { + let mut b = (u32::MAX, u32::MAX, 0, 0); + let mut tops = vec![None; img.width as usize]; + for y in 0..img.height { + for x in 0..img.width { + if img.pixel(x, y)[0] < 128 { + b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y)); + if tops[x as usize].is_none() { + tops[x as usize] = Some(y); + } + } + } + } + (b, tops) +} + +#[test] +fn wordart_arch_up_lifts_the_middle_of_the_line() { + let o = RenderOpts { scale: 1.0, ..Default::default() }; + let flat = render_slide(&wordart(None, "ARCHED WORDART"), 0, &o); + let arched = render_slide(&wordart(Some("textArchUp"), "ARCHED WORDART"), 0, &o); + assert!(diff_px(&flat, &arched) > 1000); + let ((fx0, _, fx1, _), ftops) = ink(&flat); + let ((ax0, ay0, ax1, _), atops) = ink(&arched); + let top_near = |tops: &[Option], x: u32| (x.saturating_sub(15)..x + 15).filter_map(|i| tops.get(i as usize).copied().flatten()).min(); + // Flat text: the ends and the middle stand on one line. + let (fl, fm) = (top_near(&ftops, fx0 + 20).unwrap(), top_near(&ftops, (fx0 + fx1) / 2).unwrap()); + assert!(fl.abs_diff(fm) < 4, "flat {fl} {fm}"); + // Arched: the line follows the top of the ellipse inscribed in the box, so the middle is + // at the box top and the ends sit clearly lower. + let (al, am, ar) = (top_near(&atops, ax0 + 20).unwrap(), top_near(&atops, (ax0 + ax1) / 2).unwrap(), top_near(&atops, ax1 - 20).unwrap()); + assert!(al > am + 10 && ar > am + 10, "arched {al} {am} {ar}"); + assert!((80..=110).contains(&ay0), "arch top {ay0}"); +} + +#[test] +fn wordart_plain_stretches_the_ink_over_the_text_rect() { + let img = render_slide(&wordart(Some("textPlain"), "PLAIN"), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let ((x0, y0, x1, y1), _) = ink(&img); + for (got, want) in [(x0, 100), (y0, 100), (x1, 499), (y1, 299)] { + assert!(got.abs_diff(want) <= 2, "ink box {:?}", (x0, y0, x1, y1)); + } +} + +#[test] +fn unknown_or_absent_warps_draw_flat_text() { + let o = RenderOpts { scale: 0.5, ..Default::default() }; + // Warm the font database first (other tests load fonts concurrently). + let _ = render_slide(&wordart(None, "FLAT"), 0, &o); + let flat = render_slide(&wordart(None, "FLAT"), 0, &o); + for w in ["textNoShape", "bogus", ""] { + assert_eq!(diff_px(&flat, &render_slide(&wordart(Some(w), "FLAT"), 0, &o)), 0, "{w}"); + } +} + +#[test] +fn every_warp_preset_renders_text_inside_the_slide() { + let o = RenderOpts { scale: 0.5, ..Default::default() }; + for name in + ["textArchUp", "textArchDown", "textCircle", "textButton", "textWave1", "textRingInside", "textCirclePour", "textDeflateInflateDeflate"] + { + let mut p = wordart(Some(name), "One line\nTwo line\nThree"); + if let Some(b) = std::sync::Arc::make_mut(&mut p.slides[0]).shapes[0].text.as_mut() { + b.body.warp_adj = vec![f64::NAN, -1e12]; + } + let img = render_slide(&p, 0, &o); + let ((x0, y0, x1, y1), _) = ink(&img); + assert!(x0 <= x1 && y0 <= y1, "{name}: no ink"); + } +} diff --git a/crates/render/src/warp.rs b/crates/render/src/warp.rs new file mode 100644 index 00000000..de0ff126 --- /dev/null +++ b/crates/render/src/warp.rs @@ -0,0 +1,209 @@ +//! Drawing laid-out text through a WordArt warp ([`deckcraft_geom::warp`]). +//! +//! Glyph outlines are placed in layout space, flattened, cut into short segments and every point +//! is mapped through the warp, so straight glyph edges bend with the guides. Underlines and +//! highlights are warped the same way. + +use deckcraft_geom::warp::{Warp, WarpLine}; +use deckcraft_text::{GlyphRun, TextLayout}; +use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _}; +use vello_cpu::{RenderContext, peniko}; + +use crate::{ParaState, color, glyph, scale_of, text_transform}; + +/// Most points one warped glyph may have (a hostile size or zoom can't explode the work). +const MAX_POINTS: usize = 40_000; + +/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to. +fn line_of(l: &TextLayout, para: usize, y: f64) -> Option { + let mut best: Option<(usize, f64)> = None; + for (i, li) in l.lines.iter().enumerate().filter(|(_, li)| li.para == para) { + let inside = y >= li.top - 0.01 && y <= li.bottom + 0.01; + let d = if inside { 0.0 } else { (li.baseline - y).abs() }; + if best.is_none_or(|(_, bd)| d < bd) { + best = Some((i, d)); + } + } + best.map(|(i, _)| i) +} + +/// Where a line meets a follow guide, above its baseline: 3/8 of the way up the font's +/// ascent + descent box (measured on Arial, Times New Roman and Courier New in PowerPoint). +fn ref_offset(run: &GlyphRun) -> f64 { + let f = &run.face; + let k = run.size / f.upem.max(1.0); + ((0.375 * (f.ascent + f.descent) - f.descent) * k).clamp(-run.size, run.size) +} + +/// `path` flattened with tolerance `tol`, cut into pieces of at most `seg` and mapped by `f`. +fn warp_path(path: &BezPath, tol: f64, seg: f64, f: &dyn Fn(Point) -> Point) -> BezPath { + let mut out = BezPath::new(); + let mut last: Option = None; + let mut start: Option = None; + let mut budget = MAX_POINTS; + let segment = |out: &mut BezPath, a: Point, b: Point, budget: &mut usize| { + let n = ((b - a).hypot() / seg).ceil(); + let n = if n.is_finite() { (n as usize).clamp(1, 512) } else { 1 }; + for i in 1..=n { + if *budget == 0 { + return; + } + *budget -= 1; + out.line_to(f(a.lerp(b, i as f64 / n as f64))); + } + }; + kurbo::flatten(path.iter(), tol, |el| match el { + PathEl::MoveTo(p) => { + if let (Some(a), Some(s)) = (last, start) + && (a - s).hypot() > 1e-9 + { + segment(&mut out, a, s, &mut budget); + } + out.move_to(f(p)); + last = Some(p); + start = Some(p); + } + PathEl::LineTo(p) => { + if let Some(a) = last { + segment(&mut out, a, p, &mut budget); + } + last = Some(p); + } + PathEl::ClosePath => { + if let (Some(a), Some(s)) = (last, start) { + segment(&mut out, a, s, &mut budget); + } + out.close_path(); + last = start; + } + _ => {} + }); + out +} + +struct Item<'a> { + run: &'a GlyphRun, + path: BezPath, + line: usize, + state: ParaState, +} + +/// Draw `l` (laid out in text rect `tr`, shape transform `m`) through `warp`. +pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)], warp: &Warp) { + let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); + let m = text_transform(m, tr, l.rotation); + let n = l.lines.len(); + let mut ink: Vec> = vec![None; n]; + let mut off: Vec> = vec![None; n]; + let mut items: Vec = Vec::new(); + for run in &l.runs { + let k = run.size / run.face.upem.max(1.0); + let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + for (gid, x, y) in &run.glyphs { + let Some(li) = line_of(l, run.para, *y) else { continue }; + if let Some(o) = off.get_mut(li) { + *o = Some(o.map_or(ref_offset(run), |v| v.max(ref_offset(run)))); + } + let outline = glyph(&run.face, *gid); + if outline.elements().is_empty() { + continue; + } + let mut path = (*outline).clone(); + path.apply_affine(Affine::translate((*x, *y)) * skew * Affine::scale(k)); + let b = path.bounding_box(); + if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) { + continue; + } + if let Some(slot) = ink.get_mut(li) { + *slot = Some(slot.map_or(b, |r| r.union(b))); + } + items.push(Item { run, path, line: li, state: para(run.para) }); + } + } + let lines: Vec = l + .lines + .iter() + .enumerate() + .map(|(i, li)| { + let o = off.get(i).copied().flatten().unwrap_or(0.18 * (li.bottom - li.top)); + WarpLine { ink: ink.get(i).copied().flatten(), extent: li.extent, ref_y: li.baseline - o } + }) + .collect(); + let mapper = warp.mapper(&lines, l.inner); + let px = scale_of(m).max(1e-6) * mapper.zoom(); + let tol = (0.1 / px).clamp(1e-4, 2.0); + let seg = (2.0 / px).clamp(1e-3, 50.0); + let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| { + let ps = para(d.para); + if !ps.visible || ps.opacity <= 0.001 { + return; + } + let y = (d.rect.y0 + d.rect.y1) / 2.0; + let Some(li) = line_of(l, d.para, y) else { return }; + let path = warp_path(&d.rect.to_path(0.1), tol, seg, &|p| mapper.map(li, p)); + ctx.set_transform(m * Affine::translate(ps.offset)); + ctx.set_paint(color(d.color, ps.opacity)); + ctx.fill_path(&path); + }; + for d in l.decos.iter().filter(|d| d.behind) { + deco(ctx, d); + } + for it in &items { + let (run, ps) = (it.run, it.state); + if !ps.visible || ps.opacity <= 0.001 { + continue; + } + let alpha = run.alpha * ps.opacity; + if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { + continue; + } + let path = warp_path(&it.path, tol, seg, &|p| mapper.map(it.line, p)); + ctx.set_transform(m * Affine::translate(ps.offset)); + if alpha > 0.0 && run.color.a > 0 { + ctx.set_paint(color(run.color, alpha)); + ctx.fill_path(&path); + if run.fake_bold { + ctx.set_stroke(kurbo::Stroke::new(run.size * 0.025).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } + } + if let Some((oc, ow)) = run.outline { + ctx.set_paint(color(oc, ps.opacity)); + ctx.set_stroke(kurbo::Stroke::new(ow.max(0.0)).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } + } + for d in l.decos.iter().filter(|d| !d.behind) { + deco(ctx, d); + } + ctx.set_fill_rule(peniko::Fill::NonZero); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn warp_path_subdivides_and_maps_every_point() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((10.0, 0.0)); + p.line_to((10.0, 10.0)); + p.close_path(); + let out = warp_path(&p, 0.01, 1.0, &|q| Point::new(q.x, q.y + q.x * q.x * 0.01)); + // 10 + 10 + ~15 pieces of at most 1 pt. + let n = out.elements().len(); + assert!(n > 30, "{n}"); + // The first edge is bent onto the parabola. + let mid = out.elements().iter().find_map(|e| if let PathEl::LineTo(q) = e { (q.x == 5.0).then_some(*q) } else { None }); + assert_eq!(mid, Some(Point::new(5.0, 0.25))); + // A huge path with a tiny segment length stays bounded. + let mut big = BezPath::new(); + big.move_to((0.0, 0.0)); + for i in 0..1000 { + big.line_to((1e6 * (i % 2) as f64, i as f64)); + } + let out = warp_path(&big, 0.01, 1e-3, &|q| q); + assert!(out.elements().len() <= MAX_POINTS + 2); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 32ef2f18..7144c551 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1074,7 +1074,9 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts (r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)), (r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)), ); - let wrap = bp.wrap.unwrap_or(true) && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true)); + // Text that follows a WordArt arch or circle is set on one line per paragraph. + let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow); + let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true)); let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize; let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0); let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1d87c9ac..6a8c9921 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -69,7 +69,7 @@ the round trip even though DeckCraft doesn't model it. | Effects: shadow, glow, soft edges, reflection | yes | yes | | | 3-D: `scene3d`, `sp3d` | kept | kept | Not rendered | | Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow` and date fields | -| Text warp (`prstTxWarp`) | yes | yes | Not rendered | +| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | | Tables with styles | yes | yes | | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | @@ -99,4 +99,5 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Text warps rendered; their adjust values read and written | | 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues | diff --git a/docs/gaps.md b/docs/gaps.md index 6ea4130a..a13383a3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -165,9 +165,13 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * - **Estimate:** 4–6 h. ### Edit points, text warp, 3-D -- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms", "Bevel and 3-D - rotation" (missing; `scene3d`/`sp3d` kept as raw XML on round trip). -- **Estimate:** 4–6 h (edit points), 6–10 h (warp), 8–12 h (3-D render approximation). +- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40 + `prstTxWarp` presets render and their adjusts round-trip, but there is no Transform gallery or + command to apply one, no adjust handles, and arc/circle placement can differ from PowerPoint by + 1–3 % of the path length), "Bevel and 3-D rotation" (missing; `scene3d`/`sp3d` kept as raw XML on + round trip). +- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 8–12 h (3-D render + approximation). ### Proofing: spelling, thesaurus, AutoCorrect - **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings @@ -247,5 +251,6 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles | | 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) | | 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 8c7337fa..bfd5c076 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 60 | 12 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 13 | 28 | 36% | +| P2 | 54 | 13 | 14 | 27 | 37% | | Area | Parity | |---|---| @@ -18,7 +18,7 @@ The summary between ` | Slides | 90% | | Views | 90% | | Masters & themes | 85% | -| Shapes | 86% | +| Shapes | 88% | | Format | 81% | | Arrange | 100% | | Text | 80% | @@ -115,7 +115,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh | Shapes | Change shape | P1 | D | | Shapes | Text boxes | P0 | D | | Shapes | WordArt | P1 | P | -| Shapes | Text warp transforms | P2 | M | +| Shapes | Text warp transforms | P2 | P | | Shapes | Shape quick styles | P1 | D | | Shapes | Set as default shape | P2 | D | | Format | Solid fill and transparency | P0 | D | From a293b682581b0c4f82ae0197ca3a66cac9114dda Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 18:15:54 -0400 Subject: [PATCH 28/49] Shapes: add 38 missing preset geometries (#315) lineInv, nonIsoscelesTrapezoid, pieWedge, the left-right / up-down / quad arrow callouts, circularArrow / leftCircularArrow / leftRightCircularArrow, the four curved arrows, swooshArrow, callout1-3, accentCallout1-3, borderCallout2-3, accentBorderCallout1-3, ellipseRibbon / ellipseRibbon2, leftRightRibbon, flowChartOfflineStorage, gear6 / gear9, funnel, the corner tabs (corner, square, plaque) and chartX / chartStar / chartPlus no longer fall back to plain rectangles. Each outline is our own construction in crates/geom/src/preset/extra.rs, fitted black-box against PowerPoint renders of synthetic probe decks at default, changed and extreme adjust values and several aspect ratios (outline within ~1pt on 300-480pt shapes). Tests cover catalog presence, hostile sizes/adjusts and measured outline points. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/geom/src/preset.rs | 47 +- crates/geom/src/preset/extra.rs | 796 ++++++++++++++++++++++++++++++++ docs/architecture.md | 2 +- docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 2 +- 5 files changed, 845 insertions(+), 4 deletions(-) create mode 100644 crates/geom/src/preset/extra.rs diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index 09f98698..9789f3c5 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -9,6 +9,8 @@ use kurbo::{Arc, BezPath, Point, Rect, Shape as _, Vec2}; use serde::{Deserialize, Serialize}; +mod extra; + /// How a sub-path is filled. `Lighten`/`Darken` variants shade the shape's fill (3-D looking faces). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -148,6 +150,7 @@ pub static CATALOG: &[PresetInfo] = &[ p!("bentConnector3", "Connector: Elbow", Lines, [50000]), p!("curvedConnector3", "Connector: Curved", Lines, [50000]), p!("bentConnector2", "Connector: Elbow (one bend)", Lines, []), + p!("lineInv", "Line: Rising", Lines, []), p!("rect", "Rectangle", Rectangles, []), p!("roundRect", "Rectangle: Rounded Corners", Rectangles, [16667]), p!("snip1Rect", "Rectangle: Single Corner Snipped", Rectangles, [16667]), @@ -163,6 +166,7 @@ pub static CATALOG: &[PresetInfo] = &[ p!("rtTriangle", "Right Triangle", BasicShapes, []), p!("parallelogram", "Parallelogram", BasicShapes, [25000]), p!("trapezoid", "Trapezoid", BasicShapes, [25000]), + p!("nonIsoscelesTrapezoid", "Trapezoid: Uneven Sides", BasicShapes, [25000, 25000]), p!("diamond", "Diamond", BasicShapes, []), p!("pentagon", "Regular Pentagon", BasicShapes, []), p!("hexagon", "Hexagon", BasicShapes, [25000]), @@ -171,9 +175,13 @@ pub static CATALOG: &[PresetInfo] = &[ p!("decagon", "Decagon", BasicShapes, []), p!("dodecagon", "Dodecagon", BasicShapes, []), p!("pie", "Partial Circle", BasicShapes, [0, 16200000]), + p!("pieWedge", "Quarter Wedge", BasicShapes, []), p!("chord", "Chord", BasicShapes, [2700000, 16200000]), p!("teardrop", "Teardrop", BasicShapes, [100000]), p!("frame", "Frame", BasicShapes, [12500]), + p!("cornerTabs", "Corner Tabs: Triangles", BasicShapes, []), + p!("squareTabs", "Corner Tabs: Squares", BasicShapes, []), + p!("plaqueTabs", "Corner Tabs: Quarter Circles", BasicShapes, []), p!("halfFrame", "Half Frame", BasicShapes, [33333, 33333]), p!("corner", "L-Shape", BasicShapes, [50000, 50000]), p!("diagStripe", "Diagonal Stripe", BasicShapes, [50000]), @@ -192,6 +200,9 @@ pub static CATALOG: &[PresetInfo] = &[ p!("sun", "Sun", BasicShapes, [25000]), p!("moon", "Moon", BasicShapes, [50000]), p!("cloud", "Cloud", BasicShapes, []), + p!("gear6", "Gear: 6 Teeth", BasicShapes, [15000, 3526]), + p!("gear9", "Gear: 9 Teeth", BasicShapes, [10000, 1763]), + p!("funnel", "Funnel", BasicShapes, []), p!("arc", "Arc", BasicShapes, [16200000, 0]), p!("bracketPair", "Double Bracket", BasicShapes, [16667]), p!("bracePair", "Double Brace", BasicShapes, [8333]), @@ -199,6 +210,9 @@ pub static CATALOG: &[PresetInfo] = &[ p!("rightBracket", "Right Bracket", BasicShapes, [8333]), p!("leftBrace", "Left Brace", BasicShapes, [8333, 50000]), p!("rightBrace", "Right Brace", BasicShapes, [8333, 50000]), + p!("chartX", "Chart Marker: Cross Lines", BasicShapes, []), + p!("chartStar", "Chart Marker: Star Lines", BasicShapes, []), + p!("chartPlus", "Chart Marker: Plus Lines", BasicShapes, []), p!("rightArrow", "Arrow: Right", BlockArrows, [50000, 50000]), p!("leftArrow", "Arrow: Left", BlockArrows, [50000, 50000]), p!("upArrow", "Arrow: Up", BlockArrows, [50000, 50000]), @@ -209,16 +223,27 @@ pub static CATALOG: &[PresetInfo] = &[ p!("leftRightUpArrow", "Arrow: Left-Right-Up", BlockArrows, [25000, 25000, 25000]), p!("bentArrow", "Arrow: Bent", BlockArrows, [25000, 25000, 25000, 43750]), p!("uturnArrow", "Arrow: U-Turn", BlockArrows, [25000, 25000, 25000, 43750, 75000]), + p!("curvedRightArrow", "Arrow: Curved Right", BlockArrows, [25000, 50000, 25000]), + p!("curvedLeftArrow", "Arrow: Curved Left", BlockArrows, [25000, 50000, 25000]), + p!("curvedUpArrow", "Arrow: Curved Up", BlockArrows, [25000, 50000, 25000]), + p!("curvedDownArrow", "Arrow: Curved Down", BlockArrows, [25000, 50000, 25000]), p!("leftUpArrow", "Arrow: Left-Up", BlockArrows, [25000, 25000, 25000]), p!("bentUpArrow", "Arrow: Bent-Up", BlockArrows, [25000, 25000, 25000]), p!("stripedRightArrow", "Arrow: Striped Right", BlockArrows, [50000, 50000]), p!("notchedRightArrow", "Arrow: Notched Right", BlockArrows, [50000, 50000]), p!("homePlate", "Arrow: Pentagon", BlockArrows, [50000]), p!("chevron", "Arrow: Chevron", BlockArrows, [50000]), + p!("circularArrow", "Arrow: Circular", BlockArrows, [12500, 1142319, 20457681, 10800000, 12500]), + p!("leftCircularArrow", "Arrow: Circular Counter-clockwise", BlockArrows, [12500, -1142319, 1142319, 10800000, 12500]), + p!("leftRightCircularArrow", "Arrow: Circular Two-headed", BlockArrows, [12500, 1142319, 20457681, 11942319, 12500]), + p!("swooshArrow", "Arrow: Swoosh", BlockArrows, [25000, 16667]), p!("rightArrowCallout", "Callout: Right Arrow", BlockArrows, [25000, 25000, 25000, 64977]), p!("downArrowCallout", "Callout: Down Arrow", BlockArrows, [25000, 25000, 25000, 64977]), p!("leftArrowCallout", "Callout: Left Arrow", BlockArrows, [25000, 25000, 25000, 64977]), p!("upArrowCallout", "Callout: Up Arrow", BlockArrows, [25000, 25000, 25000, 64977]), + p!("leftRightArrowCallout", "Callout: Left-Right Arrow", BlockArrows, [25000, 25000, 25000, 48123]), + p!("upDownArrowCallout", "Callout: Up-Down Arrow", BlockArrows, [25000, 25000, 25000, 48123]), + p!("quadArrowCallout", "Callout: Quad Arrow", BlockArrows, [18515, 18515, 18515, 48123]), p!("mathPlus", "Plus Sign", EquationShapes, [23520]), p!("mathMinus", "Minus Sign", EquationShapes, [23520]), p!("mathMultiply", "Multiplication Sign", EquationShapes, [23520]), @@ -253,6 +278,7 @@ pub static CATALOG: &[PresetInfo] = &[ p!("flowChartMagneticDisk", "Flowchart: Magnetic Disk", Flowchart, []), p!("flowChartMagneticDrum", "Flowchart: Direct Access Storage", Flowchart, []), p!("flowChartDisplay", "Flowchart: Display", Flowchart, []), + p!("flowChartOfflineStorage", "Flowchart: Offline Storage", Flowchart, []), p!("irregularSeal1", "Explosion: 8 Points", StarsAndBanners, []), p!("irregularSeal2", "Explosion: 14 Points", StarsAndBanners, []), p!("star4", "Star: 4 Points", StarsAndBanners, [12500]), @@ -267,6 +293,9 @@ pub static CATALOG: &[PresetInfo] = &[ p!("star32", "Star: 32 Points", StarsAndBanners, [37500]), p!("ribbon2", "Ribbon: Tilted Up", StarsAndBanners, [16667, 50000]), p!("ribbon", "Ribbon: Tilted Down", StarsAndBanners, [16667, 50000]), + p!("ellipseRibbon2", "Ribbon: Curved and Tilted Up", StarsAndBanners, [25000, 50000, 12500]), + p!("ellipseRibbon", "Ribbon: Curved and Tilted Down", StarsAndBanners, [25000, 50000, 12500]), + p!("leftRightRibbon", "Ribbon: Left-Right Fold", StarsAndBanners, [50000, 50000, 16667]), p!("verticalScroll", "Scroll: Vertical", StarsAndBanners, [12500]), p!("horizontalScroll", "Scroll: Horizontal", StarsAndBanners, [12500]), p!("wave", "Wave", StarsAndBanners, [12500, 0]), @@ -276,6 +305,22 @@ pub static CATALOG: &[PresetInfo] = &[ p!("wedgeEllipseCallout", "Speech Bubble: Oval", Callouts, [-20833, 62500]), p!("cloudCallout", "Thought Bubble: Cloud", Callouts, [-20833, 62500]), p!("borderCallout1", "Callout: Line", Callouts, [18750, -8333, 112500, -38333]), + p!("borderCallout2", "Callout: Bent Line", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), + p!("borderCallout3", "Callout: Double Bent Line", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]), + p!("accentCallout1", "Callout: Line with Accent Bar", Callouts, [18750, -8333, 112500, -38333]), + p!("accentCallout2", "Callout: Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), + p!("accentCallout3", "Callout: Double Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]), + p!("callout1", "Callout: Line with No Border", Callouts, [18750, -8333, 112500, -38333]), + p!("callout2", "Callout: Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), + p!("callout3", "Callout: Double Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]), + p!("accentBorderCallout1", "Callout: Line with Border and Accent Bar", Callouts, [18750, -8333, 112500, -38333]), + p!("accentBorderCallout2", "Callout: Bent Line with Border and Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), + p!( + "accentBorderCallout3", + "Callout: Double Bent Line with Border and Accent Bar", + Callouts, + [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333] + ), p!("actionButtonBackPrevious", "Action Button: Go Back or Previous", ActionButtons, []), p!("actionButtonForwardNext", "Action Button: Go Forward or Next", ActionButtons, []), p!("actionButtonBeginning", "Action Button: Go to Beginning", ActionButtons, []), @@ -1535,7 +1580,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { let l = Pb::new().m(0.0, 0.0).c(x / 2.0, 0.0, x, h / 4.0, x, h / 2.0).c(x, h * 0.75, (x + w) / 2.0, h, w, h).take(); Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] } } - _ => return None, + _ => return extra::build(name, w, h, &a), }; Some(g) } diff --git a/crates/geom/src/preset/extra.rs b/crates/geom/src/preset/extra.rs new file mode 100644 index 00000000..ac3848e9 --- /dev/null +++ b/crates/geom/src/preset/extra.rs @@ -0,0 +1,796 @@ +//! More preset outlines: line callouts, arrow callouts, curved and circular arrows, curved +//! ribbons, gears, tabs and chart markers. Like the rest of `preset`, every outline is our own +//! construction, fitted to how the shapes look and to what their adjust values mean. + +use super::*; + +/// Builds the presets this module knows; `None` for any other name. +pub(super) fn build(name: &str, w: f64, h: f64, a: &dyn Fn(usize) -> f64) -> Option { + let ss = w.min(h); + let full = Rect::new(0.0, 0.0, w, h); + let mut b = Pb::new(); + let g = match name { + "lineInv" => { + let l = b.m(0.0, h).l(w, 0.0).take(); + Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::new(0.0, h), Point::new(w, 0.0)] } + } + "nonIsoscelesTrapezoid" => { + let max = 50000.0 * w / ss.max(1e-9); + let (v1, v2) = (a(0).clamp(0.0, max), a(1).clamp(0.0, max)); + let (d1, d2) = (ss * v1 / 1e5, ss * v2 / 1e5); + let mut g = simple(b.poly(&[(0.0, h), (d1, 0.0), (w - d2, 0.0), (w, h)]).take(), Rect::new(d1 * 0.66, h * 0.2, w - d2 * 0.66, h), w, h); + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 1e5, 0.0), v1, 0.0, max)); + g.handles.push(Handle::new(1, Point::new(w, 0.0), Vec2::new(-ss / 1e5, 0.0), v2, 0.0, max)); + g + } + "pieWedge" => { + b.m(0.0, h).arc(w, h, 180.0, 90.0).l(w, h).z(); + simple(b.take(), Rect::new(w * 0.29289, h * 0.29289, w, h), w, h) + } + "flowChartOfflineStorage" => { + let tri = b.poly(&[(0.0, 0.0), (w, 0.0), (w / 2.0, h)]).take(); + let bar = Pb::new().m(w * 0.4, h * 0.8).l(w * 0.6, h * 0.8).take(); + Geometry { + paths: vec![fill(tri), stroke_only(bar)], + text_rect: Rect::new(w / 4.0, 0.0, w * 0.75, h * 0.8), + handles: vec![], + sites: vec![Point::new(w / 2.0, 0.0), Point::new(w / 4.0, h / 2.0), Point::new(w / 2.0, h), Point::new(w * 0.75, h / 2.0)], + } + } + "chartX" | "chartStar" | "chartPlus" => { + let body = corner_rect(w, h, [Corner::Square; 4]); + let mut l = Pb::new(); + if name != "chartPlus" { + l.m(0.0, 0.0).l(w, h).m(0.0, h).l(w, 0.0); + } + if name != "chartX" { + l.m(w / 2.0, 0.0).l(w / 2.0, h); + } + if name == "chartPlus" { + l.m(0.0, h / 2.0).l(w, h / 2.0); + } + Geometry { paths: vec![fill_only(body), stroke_only(l.take())], text_rect: full, handles: vec![], sites: mid_sites(w, h) } + } + "cornerTabs" | "squareTabs" | "plaqueTabs" => { + // Each tab's size is a twentieth of the diagonal. + let d = (w.hypot(h) / 20.0).min(w / 2.0).min(h / 2.0); + match name { + "cornerTabs" => { + b.poly(&[(0.0, 0.0), (d, 0.0), (0.0, d)]); + b.poly(&[(w - d, 0.0), (w, 0.0), (w, d)]); + b.poly(&[(w, h - d), (w, h), (w - d, h)]); + b.poly(&[(0.0, h - d), (d, h), (0.0, h)]); + } + "squareTabs" => { + b.poly(&[(0.0, 0.0), (d, 0.0), (d, d), (0.0, d)]); + b.poly(&[(w - d, 0.0), (w, 0.0), (w, d), (w - d, d)]); + b.poly(&[(w - d, h - d), (w, h - d), (w, h), (w - d, h)]); + b.poly(&[(0.0, h - d), (d, h - d), (d, h), (0.0, h)]); + } + _ => { + if d > 0.0 { + b.m(0.0, 0.0).l(d, 0.0).arc(d, d, 0.0, 90.0).z(); + b.m(w, 0.0).l(w, d).arc(d, d, 90.0, 90.0).z(); + b.m(w, h).l(w - d, h).arc(d, d, 180.0, 90.0).z(); + b.m(0.0, h).l(0.0, h - d).arc(d, d, 270.0, 90.0).z(); + } else { + b.m(0.0, 0.0).z(); + } + } + } + Geometry { paths: vec![fill(b.take())], text_rect: full.inset(-d), handles: vec![], sites: mid_sites(w, h) } + } + "callout1" + | "callout2" + | "callout3" + | "accentCallout1" + | "accentCallout2" + | "accentCallout3" + | "borderCallout2" + | "borderCallout3" + | "accentBorderCallout1" + | "accentBorderCallout2" + | "accentBorderCallout3" => { + let n = match name.chars().last() { + Some('2') => 2, + Some('3') => 3, + _ => 1, + }; + line_callout(w, h, a, n + 1, name.starts_with("accent"), name.contains("order")) + } + "leftRightArrowCallout" | "upDownArrowCallout" => { + let vertical = name == "upDownArrowCallout"; + let (l, t) = if vertical { (h, w) } else { (w, h) }; + let ss = l.min(t); + let head = (ss * a(1).max(0.0) / 1e5).min(t / 2.0); + let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0); + let hl = (ss * a(2).max(0.0) / 1e5).min(l / 2.0); + let boxl = (l * a(3).max(0.0) / 1e5).min(l - 2.0 * hl); + let (c, b0, b1) = (t / 2.0, (l - boxl) / 2.0, (l + boxl) / 2.0); + let pts = [ + (0.0, c), + (hl, c - head), + (hl, c - shaft / 2.0), + (b0, c - shaft / 2.0), + (b0, 0.0), + (b1, 0.0), + (b1, c - shaft / 2.0), + (l - hl, c - shaft / 2.0), + (l - hl, c - head), + (l, c), + (l - hl, c + head), + (l - hl, c + shaft / 2.0), + (b1, c + shaft / 2.0), + (b1, t), + (b0, t), + (b0, c + shaft / 2.0), + (hl, c + shaft / 2.0), + (hl, c + head), + ]; + let d = if vertical { Dir::Down } else { Dir::Right }; + let pts = orient(d, w, h, &pts); + let tr = orient(d, w, h, &[(b0, 0.0), (b1, t)]); + simple(b.poly(&pts).take(), Rect::from_points(Point::new(tr[0].0, tr[0].1), Point::new(tr[1].0, tr[1].1)).abs(), w, h) + } + "quadArrowCallout" => { + let head = (ss * a(1).max(0.0) / 1e5).min(ss / 2.0); + let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0); + let hl = (ss * a(2).max(0.0) / 1e5).min(ss / 2.0); + let k = a(3).clamp(0.0, 100000.0) / 1e5; + let (bw, bh) = ((w * k).min(w - 2.0 * hl).max(shaft), (h * k).min(h - 2.0 * hl).max(shaft)); + let (cx, cy) = (w / 2.0, h / 2.0); + let (bx0, bx1, by0, by1) = (cx - bw / 2.0, cx + bw / 2.0, cy - bh / 2.0, cy + bh / 2.0); + let (s2, hd) = (shaft / 2.0, head); + let pts = [ + (0.0, cy), + (hl, cy - hd), + (hl, cy - s2), + (bx0, cy - s2), + (bx0, by0), + (cx - s2, by0), + (cx - s2, hl), + (cx - hd, hl), + (cx, 0.0), + (cx + hd, hl), + (cx + s2, hl), + (cx + s2, by0), + (bx1, by0), + (bx1, cy - s2), + (w - hl, cy - s2), + (w - hl, cy - hd), + (w, cy), + (w - hl, cy + hd), + (w - hl, cy + s2), + (bx1, cy + s2), + (bx1, by1), + (cx + s2, by1), + (cx + s2, h - hl), + (cx + hd, h - hl), + (cx, h), + (cx - hd, h - hl), + (cx - s2, h - hl), + (cx - s2, by1), + (bx0, by1), + (bx0, cy + s2), + (hl, cy + s2), + (hl, cy + hd), + ]; + simple(b.poly(&pts).take(), Rect::new(bx0, by0, bx1, by1), w, h) + } + "curvedRightArrow" | "curvedLeftArrow" | "curvedUpArrow" | "curvedDownArrow" => curved_arrow(name, w, h, a(0), a(1), a(2)), + "swooshArrow" => swoosh(w, h, a(0), a(1)), + "ellipseRibbon" | "ellipseRibbon2" => ellipse_ribbon(name == "ellipseRibbon2", w, h, a(0), a(1), a(2)), + "leftRightRibbon" => left_right_ribbon(w, h, a(0), a(1), a(2)), + "gear6" | "gear9" => gear(if name == "gear6" { 6 } else { 9 }, w, h, a(0), a(1)), + "funnel" => funnel(w, h), + "circularArrow" | "leftCircularArrow" | "leftRightCircularArrow" => circular_arrow(name, w, h, [a(0), a(1), a(2), a(3), a(4)]), + _ => return None, + }; + Some(g) +} + +/// An ellipse-centred frame for the circular arrows: the band's middle runs on the ellipse +/// `(rx, ry)`, its edges `th/2` either side. +struct Ring { + c: Point, + rx: f64, + ry: f64, + th: f64, + hw: f64, +} + +impl Ring { + /// The point at visual angle `deg` on the ellipse grown by `d`. + fn at(&self, deg: f64, d: f64) -> Point { + let (ax, ay) = ((self.rx + d).max(0.0), (self.ry + d).max(0.0)); + let t = vis(deg, ax, ay).to_radians(); + Point::new(self.c.x + ax * t.cos(), self.c.y + ay * t.sin()) + } + /// Parametric angle (degrees) of `p` on the ellipse grown by `d`. + fn param(&self, p: Point, d: f64) -> f64 { + let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9)); + ((p.y - self.c.y) / ay).atan2((p.x - self.c.x) / ax).to_degrees() + } + /// Where the line `b + s·u` meets the ellipse grown by `d`, taking the crossing nearest `b` + /// on the side `sign` (+1 outwards, −1 inwards). + fn cross(&self, b: Point, u: Vec2, d: f64, sign: f64) -> Point { + let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9)); + let (px, py) = ((b.x - self.c.x) / ax, (b.y - self.c.y) / ay); + let (ux, uy) = (u.x / ax, u.y / ay); + let qa = ux * ux + uy * uy; + let qb = 2.0 * (px * ux + py * uy); + let qc = px * px + py * py - 1.0; + let disc = qb * qb - 4.0 * qa * qc; + if qa < 1e-18 || disc < 0.0 { + // The line misses (a long head on a thin ring): the edge then carries on from the + // base centre itself, as the reference does. + return b; + } + let r = disc.sqrt(); + let (s0, s1) = ((-qb - r) / (2.0 * qa), (-qb + r) / (2.0 * qa)); + let s = if sign > 0.0 { + if s0 >= 0.0 { s0 } else { s1 } + } else if s1 <= 0.0 { + s1 + } else { + s0 + }; + b + u * s + } + /// An arrow head whose base centre sits on the middle ellipse at `base` degrees and whose tip + /// sits there at `base + sweep`. The base runs along the line from the centre to the tip. + /// Returns (band outer edge, outer wing, tip, inner wing, band inner edge). + fn head(&self, base: f64, sweep: f64) -> [Point; 5] { + let b = self.at(base, 0.0); + let tip = self.at(base + sweep, 0.0); + let d = tip - self.c; + let u = if d.hypot() > 1e-12 { d / d.hypot() } else { Vec2::new(1.0, 0.0) }; + let half = self.th / 2.0; + [self.cross(b, u, half, 1.0), b + u * self.hw, tip, b - u * self.hw, self.cross(b, u, -half, -1.0)] + } +} + +/// Sweep (degrees) from `a` to `b` turning one way: positive when `dir > 0`. +fn turn(a: f64, b: f64, dir: f64) -> f64 { + if dir > 0.0 { (b - a).rem_euclid(360.0) } else { -(a - b).rem_euclid(360.0) } +} + +/// The circular arrows: a band along an ellipse with one head (`circularArrow` clockwise, +/// `leftCircularArrow` anticlockwise) or a head at each end (`leftRightCircularArrow`). +/// Adjusts: band thickness, head sweep (degrees), end angle, start angle, head half-width. +fn circular_arrow(name: &str, w: f64, h: f64, a: [f64; 5]) -> Geometry { + let ss = w.min(h); + if ss <= 0.0 { + return simple(corner_rect(w, h, [Corner::Square; 4]), Rect::new(0.0, 0.0, w, h), w, h); + } + let a5 = a[4].clamp(0.0, 25000.0); + let hw = ss * a5 / 1e5; + let th = ss * a[0].clamp(0.0, 2.0 * a5) / 1e5; + let ring = Ring { c: Point::new(w / 2.0, h / 2.0), rx: (w / 2.0 - hw).max(th / 2.0), ry: (h / 2.0 - hw).max(th / 2.0), th, hw }; + let deg = |v: f64| (v / 60000.0) % 360.0; + let (sweep, end, start) = (deg(a[1]), deg(a[2]), deg(a[3])); + let half = th / 2.0; + let mut b = Pb::new(); + // Arcs of the ellipse grown by `d`, drawn on from the current point (which is on it except in + // the degenerate case above, where the arc shifts with it like the reference's). + let edge_arc = |b: &mut Pb, from: Point, to: Point, d: f64, dir: f64| { + let (ax, ay) = ((ring.rx + d).max(0.0), (ring.ry + d).max(0.0)); + let (s, e) = (ring.param(from, d), ring.param(to, d)); + b.arc(ax, ay, s, turn(s, e, dir)); + }; + let outer_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, half, dir); + let inner_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, -half, dir); + if name == "leftRightCircularArrow" { + let hs = ring.head(start, -sweep); + let he = ring.head(end, sweep); + b.m(hs[0].x, hs[0].y); + outer_arc(&mut b, hs[0], he[0], 1.0); + b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y); + inner_arc(&mut b, he[4], hs[4], -1.0); + b.l(hs[3].x, hs[3].y).l(hs[2].x, hs[2].y).l(hs[1].x, hs[1].y).z(); + } else { + let dir = if name == "circularArrow" { 1.0 } else { -1.0 }; + let he = ring.head(end, sweep); + let (o0, i0) = (ring.at(start, half), ring.at(start, -half)); + b.m(o0.x, o0.y); + outer_arc(&mut b, o0, he[0], dir); + b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y); + inner_arc(&mut b, he[4], i0, -dir); + b.z(); + } + simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h) +} + +/// A funnel: an elliptical rim (with an open mouth) across the top quarter, and straight sides +/// down to a small rounded spout. The side lines leave the rim and meet the spout at fixed +/// angles, fitted to the reference look. +fn funnel(w: f64, h: f64) -> Geometry { + const RIM: f64 = 173.0; // where the left side leaves the rim, degrees on the rim ellipse + const SPOUT: f64 = 163.4; // where it meets the spout, degrees on the spout ellipse + let ss = w.min(h); + let (cx, rim_y) = (w / 2.0, h / 4.0); + let (sx, sy) = (w / 8.0, h / 15.0); + let spout_y = h - sy; + let at = |rx: f64, ry: f64, cy: f64, deg: f64| { + let t = deg.to_radians(); + Point::new(cx + rx * t.cos(), cy + ry * t.sin()) + }; + let p0 = at(cx, rim_y, rim_y, RIM); + let p1 = at(sx, sy, spout_y, 180.0 - SPOUT); + let mut b = Pb::new(); + b.m(p0.x, p0.y).arc(cx, rim_y, RIM, 540.0 - 2.0 * RIM); + b.l(p1.x, p1.y).arc(sx, sy, 180.0 - SPOUT, 2.0 * SPOUT - 180.0).z(); + let d = ss / 20.0; + b.ellipse(cx, rim_y, (cx - d).max(0.0), (rim_y - d).max(0.0)); + Geometry { paths: vec![fill_eo(b.take())], text_rect: Rect::new(w / 4.0, h / 2.0, w * 0.75, h - sy), handles: vec![], sites: mid_sites(w, h) } +} + +/// A gear: an elliptical body with `n` teeth, the first pointing straight up. Each tooth leaves +/// the body at two points an equal angle either side of its centre, and its tip corners sit a +/// tooth height further out along the body's normal and a flank lean further in. +fn gear(n: usize, w: f64, h: f64, a0: f64, a1: f64) -> Geometry { + let ss = w.min(h); + let th = ss * a0.clamp(0.0, 20000.0) / 1e5; // tooth height + let lean_max = if n == 6 { 5380.0 } else { 2730.0 }; + let l = ss * a1.clamp(0.0, a0.clamp(0.0, 20000.0).min(lean_max)) / 1e5; // flank lean + let (cx, cy) = (w / 2.0, h / 2.0); + let (rx, ry) = ((cx - th).max(0.0), (cy - th).max(0.0)); + let r = (ss / 2.0 - th).max(1e-9); + let half = ((th + l) / 2.0 / r).atan(); + let step = std::f64::consts::TAU / n as f64; + let on_body = |t: f64| Point::new(cx + rx * t.cos(), cy + ry * t.sin()); + let mut b = Pb::new(); + for k in 0..n { + let t = -std::f64::consts::FRAC_PI_2 + step * k as f64; + // The tooth stands along the body's normal at its centre. + let nrm = Vec2::new(t.cos() * ry, t.sin() * rx); + let u = if nrm.hypot() > 1e-12 { nrm / nrm.hypot() } else { Vec2::new(t.cos(), t.sin()) }; + let v = Vec2::new(-u.y, u.x); + let (r0, r1) = (on_body(t - half), on_body(t + half)); + let (c0, c1) = (r0 + u * th + v * l, r1 + u * th - v * l); + if k == 0 { + b.m(r0.x, r0.y); + } else { + b.arc(rx, ry, (t - step + half).to_degrees(), (step - 2.0 * half).to_degrees()); + } + b.l(c0.x, c0.y).l(c1.x, c1.y).l(r1.x, r1.y); + } + let t_last = -std::f64::consts::FRAC_PI_2 + step * (n - 1) as f64; + b.arc(rx, ry, (t_last + half).to_degrees(), (step - 2.0 * half).to_degrees()).z(); + let k = std::f64::consts::FRAC_1_SQRT_2; + let text = Rect::new(cx - rx * k, cy - ry * k, cx + rx * k, cy + ry * k); + simple(b.take(), text, w, h) +} + +/// Two arrows on a band that steps down in the middle: the left arrow sits high, the right one low, +/// and the band rolls over between them in a shaded half-ellipse curl. +fn left_right_ribbon(w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry { + let ss = w.min(h); + let sh = h * a2.clamp(0.0, 33333.0) / 1e5; // step between the two halves + let bt = (h * a0.max(0.0) / 1e5).min(h - sh); // band thickness + let cx = w / 2.0; + let (rx, ry) = (w / 32.0, (sh / 4.0).min(w / 24.0)); + let hb = (ss * a1.max(0.0) / 1e5).min(cx - rx); // head length + let yc = (h - sh) / 2.0; + let (lt, lb) = (yc - bt / 2.0, yc + bt / 2.0); + let (rt, rb) = (lt + sh, lb + sh); + // The left half: filled as a closed outline, stroked only where it can show. + let mut edge = Pb::new(); + edge.m(cx - rx, lb).l(hb, lb).l(hb, h - sh).l(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt); + edge.arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -90.0); + let edge = edge.take(); + let mut left = Pb::new(); + left.m(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt).arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -180.0); + left.l(cx, lb.max(lt + 4.0 * ry)).l(hb, lb).l(hb, h - sh).z(); + let left = left.take(); + let mut curl = Pb::new(); + curl.m(cx + rx, lt + ry).l(cx + rx, rt).l(cx, lt + 4.0 * ry).arc(rx, ry, 90.0, 180.0).arc(rx, ry, 90.0, -90.0).z(); + let mut right = Pb::new(); + right.m(w, yc + sh).l(w - hb, h).l(w - hb, rb).l(cx, rb).arc(rx, ry, 90.0, 90.0).l(cx - rx, rt - ry).arc(rx, ry, 180.0, -90.0); + right.l(w - hb, rt).l(w - hb, sh).z(); + Geometry { + paths: vec![fill_only(left), stroke_only(edge), shade(curl.take(), FillMode::DarkenLess), fill(right.take())], + text_rect: Rect::new(hb.min(cx - rx), rt, (w - hb).max(cx + rx), lb.max(rt)), + handles: vec![], + sites: vec![Point::new(0.0, yc), Point::new(cx, rt), Point::new(w, yc + sh), Point::new(cx, lb)], + } +} + +/// Adds a piece of the parabola `y = y0 + d·(1 − ((x − w/2)/(w/2))²)` from the current point (at +/// `xa`) to `xb`, as one quadratic curve. +fn sag(b: &mut Pb, w: f64, d: f64, y0: f64, xa: f64, xb: f64) { + let hw = (w / 2.0).max(1e-9); + let f = |x: f64| y0 + d * (1.0 - ((x - hw) / hw).powi(2)); + let slope = -2.0 * d * (xa - hw) / (hw * hw); + b.q((xa + xb) / 2.0, f(xa) + slope * (xb - xa) / 2.0, xb, f(xb)); +} + +/// A ribbon whose edges sag along one parabola: a front band in the middle and two notched ends +/// behind it, joined by shaded folds. Built sagging downwards; `up` mirrors it vertically. +fn ellipse_ribbon(up: bool, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry { + let th = h * a0.clamp(0.0, 100000.0) / 1e5; // band drop at the centre + // Sag of every edge: never more than the drop, never so little that the band would rise + // above the ends by more than half their height. + let d = (h * a2.max(0.0) / 1e5).max(th - (h - th) / 2.0).clamp(0.0, th); + let bw = w * a1.clamp(25000.0, 75000.0) / 1e5; + let (x0, x1) = ((w - bw) / 2.0, (w + bw) / 2.0); + let ear = w / 8.0; + let (xf, xg) = (x0 + ear, x1 - ear); + let e = h - th; // band height + let off = th - d; // band top below the ends' top + let t = |x: f64| { + let hw = (w / 2.0).max(1e-9); + d * (1.0 - ((x - hw) / hw).powi(2)) + }; + let mut ends = Pb::new(); + ends.m(0.0, 0.0); + sag(&mut ends, w, d, 0.0, 0.0, xf); + ends.l(xf, t(xf) + off).l(x0, t(x0) + off).l(x0, t(x0) + e); + sag(&mut ends, w, d, e, x0, 0.0); + ends.l(ear, t(ear) + e / 2.0).z(); + ends.m(w, 0.0).l(w - ear, t(w - ear) + e / 2.0).l(w, e); + sag(&mut ends, w, d, e, w, x1); + ends.l(x1, t(x1) + off).l(xg, t(xg) + off).l(xg, t(xg)); + sag(&mut ends, w, d, 0.0, xg, w); + ends.z(); + let mut folds = Pb::new(); + folds.poly(&[(x0, t(x0) + off), (xf, t(xf)), (xf, t(xf) + off)]); + folds.poly(&[(x1, t(x1) + off), (xg, t(xg)), (xg, t(xg) + off)]); + let mut band = Pb::new(); + band.m(x0, t(x0) + off); + sag(&mut band, w, d, off, x0, x1); + band.l(x1, t(x1) + off + e); + sag(&mut band, w, d, off + e, x1, x0); + band.z(); + let mut paths = vec![fill(ends.take()), shade(folds.take(), FillMode::DarkenLess), fill(band.take())]; + let mut text = Rect::new(x0, th, x1, t(x0) + off + e); + if up { + let m = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, h]); + for p in &mut paths { + p.path.apply_affine(m); + } + text = m.transform_rect_bbox(text); + } + Geometry { paths, text_rect: text, handles: vec![], sites: mid_sites(w, h) } +} + +/// A sweeping arrow rising from the bottom-left corner. The band's two edges are quadratic curves +/// from the corner; the head's base leans slightly forward and reaches one eighth of the shorter +/// side above and below the band. The tilt and the tip's lift were fitted to the reference look. +fn swoosh(w: f64, h: f64, a0: f64, a1: f64) -> Geometry { + const TILT: f64 = 0.1126; // tan of the head base's lean from vertical + const LIFT: f64 = 0.051; // tip height above the band's centre, per unit of height + let ss = w.min(h); + let e = ss / 8.0; + let th = (h * a0.max(0.0) / 1e5).min((h - 2.0 * e).max(0.0)); + // The head is at least long enough to keep its leaning base inside the frame. + let hl = (ss * a1.max(0.0) / 1e5).max((e + th) * TILT).min((w - e * TILT).max(0.0)); + let qx = w - hl; + let mut b = Pb::new(); + b.m(0.0, h) + .q(w / 6.0, h / 3.0, qx, e) + .l(qx - e * TILT, 0.0) + .l(w, (e + th / 2.0 - LIFT * h).clamp(0.0, 2.0 * e + th)) + .l((qx + (e + th) * TILT).min(w), 2.0 * e + th) + .l((qx + th * TILT).min(w), e + th) + .q(w / 4.0, (th + h / 12.0 + e).min(h), 0.0, h) + .z(); + simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h) +} + +/// A band that sweeps around half an ellipse and ends in an arrow head. Built pointing right +/// (`curvedRightArrow`) in a `cw`×`ch` frame, then turned for the other directions. +/// +/// Both halves of the band hang off one ellipse touching the far side: its centre sits on the +/// head side's edge, its horizontal radius is the full length, and the band has a constant +/// vertical thickness. The back half (shaded) starts flat against the head side; the front half +/// runs to the head, centred on the band where it ends. +fn curved_arrow(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry { + let along_x = matches!(name, "curvedRightArrow" | "curvedLeftArrow"); + let (cw, ch) = if along_x { (w, h) } else { (h, w) }; + let ss = cw.min(ch); + let head = (ss * a1.max(0.0) / 1e5).min(ch / 2.0); + let th = (ss * a0.max(0.0) / 1e5).min(head); + let hl = (ss * a2.max(0.0) / 1e5).min(cw); + let hh = head / 2.0; + // The band's centre line, carried on to the head side, ends at the tip, which sits half a + // head width above the bottom. + let r = ((ch - hh - th / 2.0) / 2.0).max(0.0); + // Parametric angle where the front band reaches the head base. + let te = if cw > 0.0 { (-(hl / cw)).clamp(-1.0, 1.0).acos().to_degrees() } else { 90.0 }; + let mut back = Pb::new(); + back.m(cw, 0.0).arc(cw, r, 270.0, -90.0).l(0.0, r + th).arc(cw, r, 180.0, 90.0).z(); + let mut front = Pb::new(); + front.m(0.0, r).arc(cw, r, 180.0, te - 180.0); + let yb = front.cur.y; + let yc = yb + th / 2.0; + front.l(cw - hl, yc - hh).l(cw, 2.0 * r + th / 2.0).l(cw - hl, yc + hh).l(cw - hl, yb + th).arc(cw, r, te, 180.0 - te).z(); + let m = match name { + "curvedLeftArrow" => kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]), + "curvedUpArrow" => kurbo::Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, h]), + "curvedDownArrow" => kurbo::Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0]), + _ => kurbo::Affine::IDENTITY, + }; + let (mut back, mut front) = (back.take(), front.take()); + back.apply_affine(m); + front.apply_affine(m); + Geometry { + paths: vec![shade(back, FillMode::DarkenLess), fill(front)], + text_rect: Rect::new(0.0, 0.0, w, h), + handles: vec![], + sites: mid_sites(w, h), + } +} + +/// A rectangle with a callout leader through `pts` points (`(y, x)` adjust pairs in 1/100 000 of +/// the height and width), optionally with an accent bar at the first point's x and a border. +fn line_callout(w: f64, h: f64, a: &dyn Fn(usize) -> f64, pts: usize, accent: bool, border: bool) -> Geometry { + let body = corner_rect(w, h, [Corner::Square; 4]); + // Leaders may reach well outside the box, but not without bound. + let pt = |i: usize| (w * a(2 * i + 1).clamp(-1e7, 1e7) / 1e5, h * a(2 * i).clamp(-1e7, 1e7) / 1e5); + let mut l = Pb::new(); + let (x0, y0) = pt(0); + l.m(x0, y0); + for i in 1..pts { + let (x, y) = pt(i); + l.l(x, y); + } + let mut paths = vec![if border { fill(body) } else { fill_only(body) }]; + if accent { + paths.push(stroke_only(Pb::new().m(x0, 0.0).l(x0, h).take())); + } + paths.push(stroke_only(l.take())); + Geometry { paths, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: mid_sites(w, h) } +} + +#[cfg(test)] +mod tests { + use super::super::{CATALOG, FillMode, build, info}; + use kurbo::{BezPath, PathEl, Point, Shape}; + + const NEW: [&str; 38] = [ + "lineInv", + "nonIsoscelesTrapezoid", + "pieWedge", + "leftRightArrowCallout", + "upDownArrowCallout", + "quadArrowCallout", + "circularArrow", + "leftCircularArrow", + "leftRightCircularArrow", + "curvedRightArrow", + "curvedLeftArrow", + "curvedUpArrow", + "curvedDownArrow", + "swooshArrow", + "callout1", + "callout2", + "callout3", + "accentCallout1", + "accentCallout2", + "accentCallout3", + "borderCallout2", + "borderCallout3", + "accentBorderCallout1", + "accentBorderCallout2", + "accentBorderCallout3", + "ellipseRibbon", + "ellipseRibbon2", + "leftRightRibbon", + "flowChartOfflineStorage", + "gear6", + "gear9", + "funnel", + "cornerTabs", + "squareTabs", + "plaqueTabs", + "chartX", + "chartStar", + "chartPlus", + ]; + + fn outline(name: &str, w: f64, h: f64, adj: &[f64]) -> BezPath { + build(name, w, h, adj).map(|g| g.outline()).unwrap_or_default() + } + + /// Distance from `p` to the nearest point of the (flattened) outline. + fn dist(path: &BezPath, p: Point) -> f64 { + let mut best = f64::MAX; + let mut prev = Point::ZERO; + let mut start = Point::ZERO; + kurbo::flatten(path.iter(), 0.01, |el| { + let (a, b) = match el { + PathEl::MoveTo(q) => { + prev = q; + start = q; + return; + } + PathEl::LineTo(q) => (prev, q), + PathEl::ClosePath => (prev, start), + _ => return, + }; + let d = b - a; + let t = if d.hypot2() > 0.0 { ((p - a).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 }; + best = best.min((a + d * t - p).hypot()); + prev = b; + }); + best + } + + /// Asserts every point lies within 1pt of the outline. + fn on(name: &str, w: f64, h: f64, adj: &[f64], pts: &[(f64, f64)]) { + let path = outline(name, w, h, adj); + for &(x, y) in pts { + let d = dist(&path, Point::new(x, y)); + assert!(d < 1.0, "{name} {w}x{h} {adj:?}: ({x}, {y}) is {d:.2} from the outline"); + } + } + + #[test] + fn new_presets_are_in_the_catalog() { + for n in NEW { + let i = info(n).unwrap_or_else(|| panic!("{n} missing from the catalog")); + assert!(!i.label.is_empty()); + } + assert!(CATALOG.len() >= 183); + } + + #[test] + fn new_presets_build_within_bounds_at_many_sizes_and_adjusts() { + let sizes = [(480.0, 300.0), (300.0, 300.0), (200.0, 460.0), (100.0, 460.0), (1.0, 1.0), (0.0, 0.0), (0.0, 50.0), (60.0, 0.0)]; + let adjusts: [&[f64]; 8] = [ + &[], + &[0.0; 8], + &[100000.0; 8], + &[-50000.0; 8], + &[1e12, -1e12, 1e12, -1e12, 1e12, -1e12, 1e12, -1e12], + &[f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN], + &[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 5.0, 6.0, 7.0, 8.0], + &[30000.0, 60000.0, 20000.0, 9000000.0, 20000.0, 40000.0, 120000.0, -30000.0], + ]; + for n in NEW { + // Line callouts' leaders may reach anywhere, by design. + let callout = n.contains("allout") && !n.contains("Arrow"); + for (w, h) in sizes { + for adj in adjusts { + let g = build(n, w, h, adj).unwrap_or_else(|| panic!("{n} did not build")); + assert!(!g.paths.is_empty(), "{n} has no paths"); + for s in &g.paths { + let bb = s.path.bounding_box(); + assert!(bb.x0.is_finite() && bb.y0.is_finite() && bb.x1.is_finite() && bb.y1.is_finite(), "{n} {w}x{h} {adj:?}: {bb:?}"); + if !callout { + // Circular arrows with a head longer than their ring can hold carry the + // inner edge on from the head, as the reference does, so may spill. + let spill = if n.contains("ircular") { 0.5 } else { 0.01 }; + let tol = 0.5 + w.max(h) * spill; + assert!( + bb.x0 >= -tol && bb.y0 >= -tol && bb.x1 <= w + tol && bb.y1 <= h + tol, + "{n} {w}x{h} {adj:?} out of bounds: {bb:?}" + ); + } + } + let t = g.text_rect; + assert!(t.x0.is_finite() && t.y0.is_finite() && t.x1.is_finite() && t.y1.is_finite(), "{n} text rect {t:?}"); + } + } + let g = build(n, 480.0, 300.0, &[]).unwrap_or_else(|| panic!("{n}")); + assert!(g.outline().elements().len() >= 2, "{n} default outline is empty"); + } + } + + // The spot checks below are outline points measured on reference renders. + + #[test] + fn simple_new_presets_match_reference_points() { + on("lineInv", 480.0, 300.0, &[], &[(0.0, 300.0), (480.0, 0.0)]); + on("nonIsoscelesTrapezoid", 480.0, 300.0, &[], &[(75.0, 0.0), (405.0, 0.0), (0.0, 300.0)]); + on("nonIsoscelesTrapezoid", 200.0, 460.0, &[80000.0, 20000.0], &[(100.0, 0.0), (160.0, 0.0)]); + on("pieWedge", 480.0, 300.0, &[], &[(64.3, 150.0), (0.0, 300.0), (480.0, 0.0)]); + on("flowChartOfflineStorage", 480.0, 300.0, &[], &[(240.0, 300.0), (192.0, 240.0), (288.0, 240.0)]); + on("squareTabs", 480.0, 300.0, &[], &[(28.3, 28.3), (451.7, 271.7)]); + on("cornerTabs", 480.0, 300.0, &[], &[(23.3, 5.0)]); + on("plaqueTabs", 480.0, 300.0, &[], &[(27.85, 5.0)]); + on("chartStar", 480.0, 300.0, &[], &[(240.0, 0.0), (240.0, 300.0), (48.0, 30.0)]); + on("chartPlus", 480.0, 300.0, &[], &[(0.0, 150.0), (240.0, 0.0)]); + // Chart markers and plain callouts leave the box unstroked. + for n in ["chartX", "callout1", "accentCallout2"] { + let g = build(n, 100.0, 100.0, &[]).unwrap_or_else(|| panic!("{n}")); + assert!(!g.paths[0].stroke, "{n}"); + } + } + + #[test] + fn line_callouts_follow_their_adjusts() { + on("callout3", 480.0, 300.0, &[], &[(-40.0, 56.25), (-80.0, 56.25), (-80.0, 300.0), (-40.0, 338.9)]); + let adj = [50000.0, 110000.0, 20000.0, 120000.0, -20000.0, 80000.0, 30000.0, 40000.0]; + let g = build("accentBorderCallout3", 480.0, 300.0, &adj).unwrap_or_else(|| panic!("accentBorderCallout3")); + assert_eq!(g.paths.len(), 3); + assert!(g.paths[0].stroke); + let accent = g.paths[1].path.bounding_box(); + assert!((accent.x0 - 528.0).abs() < 1e-6 && accent.y0 == 0.0 && accent.y1 == 300.0, "{accent:?}"); + on("accentBorderCallout3", 480.0, 300.0, &adj, &[(576.0, 60.0), (384.0, -60.0), (192.0, 90.0)]); + } + + #[test] + fn arrow_callouts_match_reference_points() { + on("leftRightArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (75.0, 75.0), (124.5, 0.0), (355.5, 300.0), (480.0, 150.0)]); + on("leftRightArrowCallout", 200.0, 460.0, &[], &[(50.0, 180.0), (52.0, 0.0), (148.0, 460.0)]); + on("upDownArrowCallout", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 77.8), (480.0, 222.2), (240.0, 300.0)]); + on("quadArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (124.5, 77.8), (240.0, 0.0), (480.0, 150.0), (240.0, 300.0)]); + } + + #[test] + fn curved_arrows_match_reference_points() { + on( + "curvedRightArrow", + 480.0, + 300.0, + &[], + &[(480.0, 0.0), (480.0, 75.0), (480.0, 225.0), (405.0, 149.0), (405.0, 299.0), (100.0, 151.5), (300.0, 6.7)], + ); + on("curvedRightArrow", 200.0, 460.0, &[30000.0, 60000.0, 40000.0], &[(200.0, 400.0), (120.0, 324.5), (120.0, 443.5), (200.0, 60.0)]); + // The band never outgrows the head; the head never outgrows half the frame. + on("curvedRightArrow", 200.0, 460.0, &[60000.0, 30000.0, 80000.0], &[(200.0, 60.0)]); + on("curvedLeftArrow", 480.0, 300.0, &[], &[(0.0, 225.0), (0.0, 0.0)]); + on("curvedUpArrow", 480.0, 300.0, &[], &[(405.0, 0.0), (0.0, 0.0), (75.0, 0.0)]); + on("curvedDownArrow", 480.0, 300.0, &[], &[(405.0, 300.0), (0.0, 300.0)]); + let g = build("curvedRightArrow", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("curvedRightArrow")); + assert_eq!(g.paths[0].fill, FillMode::DarkenLess); + } + + #[test] + fn swoosh_matches_reference_points() { + on("swooshArrow", 480.0, 300.0, &[], &[(0.0, 300.0), (425.8, 0.0), (480.0, 59.7), (442.8, 150.0), (202.0, 105.5), (202.0, 159.0)]); + on("swooshArrow", 480.0, 300.0, &[40000.0, 16667.0], &[(480.0, 82.2), (447.8, 195.0)]); + on("swooshArrow", 200.0, 460.0, &[], &[(163.9, 0.0), (200.0, 59.4)]); + } + + #[test] + fn ribbons_match_reference_points() { + on( + "ellipseRibbon", + 480.0, + 300.0, + &[], + &[(0.0, 0.0), (120.0, 28.1), (60.0, 128.9), (240.0, 300.0), (120.0, 66.1), (180.0, 72.2), (180.0, 35.2)], + ); + on("ellipseRibbon", 480.0, 300.0, &[30000.0, 60000.0, 20000.0], &[(120.0, 45.0), (240.0, 300.0), (96.0, 68.4)]); + on("ellipseRibbon", 480.0, 300.0, &[80000.0, 20000.0, 60000.0], &[(240.0, 240.0), (1.0, 61.75)]); + on("ellipseRibbon2", 480.0, 300.0, &[], &[(0.0, 300.0), (240.0, 0.0)]); + on( + "leftRightRibbon", + 480.0, + 300.0, + &[], + &[(0.0, 125.0), (150.0, 0.0), (150.0, 50.0), (255.0, 62.5), (225.0, 87.5), (480.0, 175.0), (330.0, 300.0)], + ); + on("leftRightRibbon", 480.0, 300.0, &[90000.0, 10000.0, 60000.0], &[(0.0, 100.0), (480.0, 200.0), (240.0, 300.0)]); + } + + #[test] + fn gears_and_funnel_match_reference_points() { + // Square gear6: body radius 105, top tooth tip 3.5 inside the frame, tip half-width ~16.3. + on("gear6", 300.0, 300.0, &[], &[(150.0, 3.5), (133.7, 3.5), (166.3, 3.5), (45.0, 150.0)]); + on("gear9", 300.0, 300.0, &[], &[(150.0, 1.0), (30.0, 150.0)]); + on("gear6", 480.0, 300.0, &[], &[(240.0, 3.5), (201.0, 3.5)]); + on("funnel", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 75.0), (240.0, 15.0), (15.0, 75.0), (240.0, 300.0), (60.5, 150.0)]); + let g = build("funnel", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("funnel")); + assert!(g.paths[0].even_odd); + } + + #[test] + fn circular_arrows_match_reference_points() { + on("circularArrow", 300.0, 300.0, &[], &[(19.0, 150.0), (56.5, 150.0), (150.0, 19.0), (262.4, 150.0), (218.6, 113.4), (293.6, 113.4)]); + on( + "circularArrow", + 300.0, + 300.0, + &[20000.0, 1800000.0, 18000000.0, 5400000.0, 20000.0], + &[(150.0, 270.0), (150.0, 210.0), (247.0, 42.1), (143.0, 102.1), (227.9, 105.0)], + ); + on("leftCircularArrow", 300.0, 300.0, &[], &[(37.6, 150.0), (262.4, 150.0), (150.0, 281.0)]); + on("leftRightCircularArrow", 300.0, 300.0, &[], &[(262.4, 150.0), (37.6, 150.0), (150.0, 19.0)]); + // The band never grows past twice the head's half-width. + on("circularArrow", 300.0, 300.0, &[25000.0, 1142319.0, 20457681.0, 10800000.0, 5000.0], &[(0.0, 150.0), (30.0, 150.0)]); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 84a3f1c6..58b8c8be 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ nothing below L6 uses egui, eframe, winit or rfd. | Layer | Crates | What they do | |---|---|---| -| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~150 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder | +| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~180 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder | | L1 | `model`, `fonts`; `h264`, `hevc`, `vp9`, `av1`, `isobmff` | Presentation, masters, layouts, slides, shapes, text, themes, tables, charts, transitions, animations, placeholder inheritance (`resolve`); font database (craft-fonts + system) with metric-compatible substitutes for Office fonts (Carlito for Calibri, Caladea for Cambria, Liberation for Arial/Times); video decoders and the MP4/MOV demuxer | | L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock | | L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6a8c9921..2bf1d288 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -63,7 +63,7 @@ the round trip even though DeckCraft doesn't model it. | Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept | | Notes slides and notes master | yes | yes | No notes-master editing view | | Handout master | no | no | | -| Shapes: preset geometry (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` | +| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`) | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | | | Lines: dash, compound, caps, joins, arrowheads | yes | yes | | | Effects: shadow, glow, soft edges, reflection | yes | yes | | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0c27d893..99653dc7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -212,7 +212,7 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc header/footer fields, embedded media, unlimited undo on shared slide snapshots. - **Themes:** 8 original themes with colour and font schemes, custom colours and fonts, backgrounds (solid, gradient, picture, pattern), slide size presets. -- **Shapes:** ~150 preset geometries with adjustment handles, text boxes, pictures (crop, +- **Shapes:** ~180 preset geometries with adjustment handles, text boxes, pictures (crop, corrections, animated GIFs that play and pause), tables with styles, charts (column, bar, line, area, pie, doughnut, scatter and more), basic SmartArt, groups, connectors glued to sites, freeform/curve/scribble, merge shapes, ink, action buttons, WordArt; flipped shapes keep their From f8d54d7af4a1e830eda2bf74cf9903010daabd2d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 18:27:19 -0400 Subject: [PATCH 29/49] Pictures: WMF and EMF draw, with the metafile reader ported from WordCraft (#317) Co-authored-by: Claude Opus 5.5 (1M context) --- Cargo.lock | 9 + Cargo.toml | 1 + crates/metafile/Cargo.toml | 13 + crates/metafile/src/bytes.rs | 59 ++ crates/metafile/src/canvas.rs | 574 +++++++++++++++++++ crates/metafile/src/dib.rs | 259 +++++++++ crates/metafile/src/emf.rs | 315 ++++++++++ crates/metafile/src/geom.rs | 118 ++++ crates/metafile/src/lib.rs | 208 +++++++ crates/metafile/src/place.rs | 124 ++++ crates/metafile/src/tests.rs | 1018 +++++++++++++++++++++++++++++++++ crates/metafile/src/wmf.rs | 273 +++++++++ crates/render/Cargo.toml | 1 + crates/render/src/images.rs | 15 +- crates/render/src/lib.rs | 1 + crates/render/src/metafile.rs | 105 ++++ crates/render/src/tests.rs | 70 +++ docs/file-format-parity.md | 4 +- docs/gaps.md | 16 +- docs/roadmap.md | 2 +- xtask/src/layers.rs | 2 + 21 files changed, 3173 insertions(+), 14 deletions(-) create mode 100644 crates/metafile/Cargo.toml create mode 100644 crates/metafile/src/bytes.rs create mode 100644 crates/metafile/src/canvas.rs create mode 100644 crates/metafile/src/dib.rs create mode 100644 crates/metafile/src/emf.rs create mode 100644 crates/metafile/src/geom.rs create mode 100644 crates/metafile/src/lib.rs create mode 100644 crates/metafile/src/place.rs create mode 100644 crates/metafile/src/tests.rs create mode 100644 crates/metafile/src/wmf.rs create mode 100644 crates/render/src/metafile.rs diff --git a/Cargo.lock b/Cargo.lock index 65028e5a..e8c93986 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1046,6 +1046,14 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "deckcraft-metafile" +version = "0.6.1" +dependencies = [ + "kurbo", + "thiserror 2.0.21", +] + [[package]] name = "deckcraft-model" version = "0.6.1" @@ -1103,6 +1111,7 @@ dependencies = [ "deckcraft-color", "deckcraft-fonts", "deckcraft-geom", + "deckcraft-metafile", "deckcraft-model", "deckcraft-text", "image", diff --git a/Cargo.toml b/Cargo.toml index 90715e5f..b8596156 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ repository = "https://github.com/storytold/deckcraft" # internal deckcraft-geom = { path = "crates/geom" } deckcraft-color = { path = "crates/color" } +deckcraft-metafile = { path = "crates/metafile" } deckcraft-model = { path = "crates/model" } deckcraft-fonts = { path = "crates/fonts" } deckcraft-text = { path = "crates/text" } diff --git a/crates/metafile/Cargo.toml b/crates/metafile/Cargo.toml new file mode 100644 index 00000000..63f7ee77 --- /dev/null +++ b/crates/metafile/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "deckcraft-metafile" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DeckCraft metafiles (ported from WordCraft): parses Windows WMF and EMF records into a neutral vector model (paths and bitmaps)" + +[lints] +workspace = true + +[dependencies] +kurbo = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/metafile/src/bytes.rs b/crates/metafile/src/bytes.rs new file mode 100644 index 00000000..5584d6df --- /dev/null +++ b/crates/metafile/src/bytes.rs @@ -0,0 +1,59 @@ +//! Bounds-checked little-endian access to hostile input. Every read returns `None` past the end, so +//! a truncated or lying size field stops the parse instead of panicking. + +#[derive(Clone, Copy)] +pub(crate) struct Bytes<'a>(pub(crate) &'a [u8]); + +impl<'a> Bytes<'a> { + pub fn slice(&self, off: usize, len: usize) -> Option<&'a [u8]> { + self.0.get(off..off.checked_add(len)?) + } + + pub fn tail(&self, off: usize) -> Option> { + self.0.get(off..).map(Bytes) + } + + fn arr(&self, off: usize) -> Option<[u8; N]> { + self.slice(off, N)?.try_into().ok() + } + + pub fn u16(&self, off: usize) -> Option { + Some(u16::from_le_bytes(self.arr(off)?)) + } + + pub fn i16(&self, off: usize) -> Option { + Some(i16::from_le_bytes(self.arr(off)?)) + } + + pub fn u32(&self, off: usize) -> Option { + Some(u32::from_le_bytes(self.arr(off)?)) + } + + pub fn i32(&self, off: usize) -> Option { + Some(i32::from_le_bytes(self.arr(off)?)) + } + + pub fn f32(&self, off: usize) -> Option { + Some(f32::from_le_bytes(self.arr(off)?)) + } + + /// `n` consecutive (x, y) pairs of 16- or 32-bit signed integers starting at `off`. + pub fn points(&self, off: usize, n: usize, wide: bool) -> Option> { + let sz = if wide { 8 } else { 4 }; + let raw = self.slice(off, n.checked_mul(sz)?)?; + let mut out = Vec::with_capacity(n); + for c in raw.chunks_exact(sz) { + let p = if wide { + let x = i32::from_le_bytes(c.get(0..4)?.try_into().ok()?); + let y = i32::from_le_bytes(c.get(4..8)?.try_into().ok()?); + (f64::from(x), f64::from(y)) + } else { + let x = i16::from_le_bytes(c.get(0..2)?.try_into().ok()?); + let y = i16::from_le_bytes(c.get(2..4)?.try_into().ok()?); + (f64::from(x), f64::from(y)) + }; + out.push(p); + } + Some(out) + } +} diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs new file mode 100644 index 00000000..fa8bfdcf --- /dev/null +++ b/crates/metafile/src/canvas.rs @@ -0,0 +1,574 @@ +//! Drawing state shared by the WMF and EMF readers: window/viewport mapping and the EMF world +//! transform, pen and brush selection through an object table, the DC save stack and path brackets. +//! Output is device-space items; `lib.rs` maps them into the picture frame. + +use crate::dib::Rgba; +use crate::geom::{Op, P}; +use crate::{MAX_BYTES, MAX_ITEMS, MAX_OBJECTS, MAX_PATH_OPS, MAX_STACK}; + +pub(crate) const MM_TEXT: u32 = 1; +const MM_ISOTROPIC: u32 = 7; +const MM_ANISOTROPIC: u32 = 8; +const STOCK: u32 = 0x8000_0000; +pub(crate) const PS_NULL: u32 = 5; +/// Pen style bits; the rest of the style word holds end-cap, join and type flags. +pub(crate) const PS_STYLE_MASK: u32 = 0x0F; +const BS_SOLID: u32 = 0; +pub(crate) const BS_NULL: u32 = 1; +const WINDING: u32 = 2; +/// SETROP2 mix mode that leaves the destination alone: outlines and fills draw nothing. +const R2_NOP: u32 = 11; +const PATCOPY: u32 = 0x00F0_0021; +const BLACKNESS: u32 = 0x0000_0042; +const WHITENESS: u32 = 0x00FF_0062; +/// Device pixels per millimetre assumed for WMF (96 dpi), and for EMF files without a device size. +const DEFAULT_PX_PER_MM: f64 = 96.0 / 25.4; + +/// Affine transform `[m11, m12, m21, m22, dx, dy]` in EMF XFORM order: `x' = x·m11 + y·m21 + dx`. +pub(crate) type Xform = [f64; 6]; +const IDENTITY: Xform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + +/// `a` then `b` in EMF row-vector convention: the matrix product `a × b`. +pub(crate) fn mul(a: Xform, b: Xform) -> Xform { + [ + a[0] * b[0] + a[1] * b[2], + a[0] * b[1] + a[1] * b[3], + a[2] * b[0] + a[3] * b[2], + a[2] * b[1] + a[3] * b[3], + a[4] * b[0] + a[5] * b[2] + b[4], + a[4] * b[1] + a[5] * b[3] + b[5], + ] +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) struct Pen { + pub style: u32, + /// Logical width; 0 means a cosmetic one-pixel hairline. + pub width: f64, + pub color: [u8; 4], +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) struct Brush { + pub style: u32, + pub color: [u8; 4], +} + +#[derive(Clone, Copy, Debug)] +pub(crate) enum Obj { + Pen(Pen), + Brush(Brush), + /// Fonts, palettes, pattern brushes and regions: they take a table slot but draw nothing. + Other, +} + +/// Converts a COLORREF (0x00BBGGRR) to opaque RGBA. +pub(crate) fn colorref(c: u32) -> [u8; 4] { + [(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8, ((c >> 16) & 0xFF) as u8, 255] +} + +/// Millimetres per logical unit of the metric map modes (LOMETRIC, HIMETRIC, LOENGLISH, HIENGLISH, TWIPS). +fn metric_mm(mode: u32) -> Option { + match mode { + 2 => Some(0.1), + 3 => Some(0.01), + 4 => Some(25.4 / 100.0), + 5 => Some(25.4 / 1000.0), + 6 => Some(25.4 / 1440.0), + _ => None, + } +} + +#[derive(Clone, Copy)] +pub(crate) struct Mapping { + wo: P, + we: P, + vo: P, + ve: P, + mode: u32, + world: Xform, + /// Device pixels per millimetre on each axis (EMF: from szlDevice and szlMillimeters). + px_mm: P, +} + +impl Mapping { + fn identity() -> Self { + Mapping { + wo: (0.0, 0.0), + we: (1.0, 1.0), + vo: (0.0, 0.0), + ve: (1.0, 1.0), + mode: MM_TEXT, + world: IDENTITY, + px_mm: (DEFAULT_PX_PER_MM, DEFAULT_PX_PER_MM), + } + } + + /// Logical point to device point: world transform, then window to viewport. + pub fn device(&self, p: P) -> P { + let w = self.world; + let (x, y) = (p.0 * w[0] + p.1 * w[2] + w[4], p.0 * w[1] + p.1 * w[3] + w[5]); + let (sx, sy) = self.ratios(); + ((x - self.wo.0) * sx + self.vo.0, (y - self.wo.1) * sy + self.vo.1) + } + + /// Window-to-viewport scale per axis. MM_TEXT fixes the scale at 1; the metric modes use a fixed + /// scale with y up; MM_ISOTROPIC uses one scale on both axes (the smaller magnitude, each axis's + /// sign kept); anisotropic and unknown modes use the extents as given. + fn ratios(&self) -> (f64, f64) { + if self.mode == MM_TEXT { + return (1.0, 1.0); + } + if let Some(mm) = metric_mm(self.mode) { + return (mm * self.px_mm.0, -mm * self.px_mm.1); + } + let (rx, ry) = (ratio(self.ve.0, self.we.0), ratio(self.ve.1, self.we.1)); + if self.mode == MM_ISOTROPIC { + let s = rx.abs().min(ry.abs()); + return (if rx < 0.0 { -s } else { s }, if ry < 0.0 { -s } else { s }); + } + (rx, ry) + } + + /// Approximate device length per logical length, for pen widths. + fn scale(&self) -> f64 { + let w = self.world; + let det = (w[0] * w[3] - w[1] * w[2]).abs().sqrt(); + let (sx, sy) = self.ratios(); + det * (sx.abs() + sy.abs()) / 2.0 + } + + /// True when the window-to-viewport extents set the scale (the isotropic and anisotropic modes). + pub fn uses_extents(&self) -> bool { + matches!(self.mode, MM_ISOTROPIC | MM_ANISOTROPIC) + } + + pub fn window_ext(&self) -> P { + self.we + } + + pub fn viewport_ext(&self) -> P { + self.ve + } + + /// The window in device units as `[x, y, w, h]` with positive extents, used as the picture frame + /// of a plain WMF. + pub fn window_rect(&self) -> [f64; 4] { + let a = self.device(self.wo); + let b = self.device((self.wo.0 + self.we.0, self.wo.1 + self.we.1)); + [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()] + } +} + +fn ratio(num: f64, den: f64) -> f64 { + if den == 0.0 { 1.0 } else { num / den } +} + +/// Output of drawing, in device units. Path points are already mapped; `lib.rs` normalises them. +pub(crate) enum Raw { + Path { ops: Vec, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool }, + Bitmap { rect: [f64; 4], img: Rgba }, +} + +#[derive(Clone)] +struct Saved { + map: Mapping, + pen: Pen, + brush: Brush, + even_odd: bool, + nop: bool, +} + +pub(crate) struct Canvas { + pub items: Vec, + pub map: Mapping, + /// True once SETWINDOWEXT was seen, so a plain WMF has a frame to use. + pub window_set: bool, + /// True once the file set the viewport extent itself (not the player, see [`Canvas::play_into`]). + pub viewport_set: bool, + saved: Vec, + /// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`]. + used: usize, + objects: Vec>, + pen: Pen, + brush: Brush, + even_odd: bool, + /// The mix mode is R2_NOP: shapes draw nothing. + nop: bool, + pos: P, + in_path: bool, + path: Vec, + full: bool, +} + +impl Canvas { + pub fn new() -> Self { + Canvas { + items: Vec::new(), + map: Mapping::identity(), + window_set: false, + viewport_set: false, + saved: Vec::new(), + used: 0, + objects: Vec::new(), + pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] }, + brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] }, + even_odd: true, + nop: false, + pos: (0.0, 0.0), + in_path: false, + path: Vec::new(), + full: false, + } + } + + /// True once the item cap is hit; the readers stop there and keep what they have. + pub fn full(&self) -> bool { + self.full + } + + fn push(&mut self, raw: Raw) { + let cost = match &raw { + Raw::Path { ops, .. } => ops.len() * std::mem::size_of::(), + Raw::Bitmap { img, .. } => img.px.len(), + }; + if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES { + self.full = true; + } else { + self.used += cost; + self.items.push(raw); + } + } + + pub fn set_window_org(&mut self, p: P) { + self.map.wo = p; + } + + pub fn set_window_ext(&mut self, e: P) { + self.map.we = e; + self.window_set = true; + } + + pub fn set_viewport_org(&mut self, p: P) { + self.map.vo = p; + } + + pub fn set_viewport_ext(&mut self, e: P) { + self.map.ve = e; + self.viewport_set = true; + } + + /// Sets up the mapping the way a player does before it plays a WMF into a target: anisotropic, + /// `window` (`[x, y, w, h]` in logical units) onto a viewport of `size` at the origin. The file's + /// own window records then replace the window; the viewport stays unless the file sets one. + /// Without a window (a plain WMF) only the viewport extent is set: the map mode and viewport + /// origin stay the file's. + pub fn play_into(&mut self, window: Option<[f64; 4]>, size: P) { + if let Some([x, y, w, h]) = window { + self.map.mode = MM_ANISOTROPIC; + self.map.wo = (x, y); + self.map.we = (w, h); + self.map.vo = (0.0, 0.0); + } + self.map.ve = size; + } + + pub fn set_map_mode(&mut self, mode: u32) { + self.map.mode = mode; + } + + /// Device pixels per millimetre (x, y) for the metric map modes. Ignored unless both are positive. + pub fn set_device_mm(&mut self, px_mm: P) { + if px_mm.0.is_finite() && px_mm.1.is_finite() && px_mm.0 > 0.0 && px_mm.1 > 0.0 { + self.map.px_mm = px_mm; + } + } + + pub fn set_polyfill(&mut self, mode: u32) { + self.even_odd = mode != WINDING; + } + + /// SETROP2: only R2_NOP changes what is drawn (nothing); the other mix modes draw as copy. + pub fn set_rop2(&mut self, mode: u32) { + self.nop = mode == R2_NOP; + } + + /// EMF SETWORLDTRANSFORM (`mode` 4) and MODIFYWORLDTRANSFORM (1 identity, 2 left, 3 right, 4 set). + /// An unknown mode leaves the world transform as it was. + pub fn modify_world(&mut self, x: Xform, mode: u32) { + match mode { + 1 => self.map.world = IDENTITY, + 2 => self.map.world = mul(x, self.map.world), + 3 => self.map.world = mul(self.map.world, x), + 4 => self.map.world = x, + _ => {} + } + } + + /// Pushes the drawing state. Past [`MAX_STACK`] levels the parse stops, since later restores + /// could no longer match the file. + pub fn save_dc(&mut self) { + if self.saved.len() < MAX_STACK { + self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop }); + } else { + self.full = true; + } + } + + /// Negative `n` pops `-n` levels; positive `n` restores absolute level `n`. + pub fn restore_dc(&mut self, n: i32) { + let idx = if n < 0 { self.saved.len().checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1) }; + let Some(idx) = idx else { return }; + let Some(s) = self.saved.get(idx).cloned() else { return }; + self.saved.truncate(idx); + self.map = s.map; + self.pen = s.pen; + self.brush = s.brush; + self.even_odd = s.even_odd; + self.nop = s.nop; + } + + /// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table). + pub fn add_object(&mut self, slot: Option, obj: Obj) { + match slot { + Some(i) if i < MAX_OBJECTS => { + if self.objects.len() <= i { + self.objects.resize(i + 1, None); + } + if let Some(s) = self.objects.get_mut(i) { + *s = Some(obj); + } + } + Some(_) => {} + None => match self.objects.iter().position(Option::is_none) { + Some(i) => { + if let Some(s) = self.objects.get_mut(i) { + *s = Some(obj); + } + } + None if self.objects.len() < MAX_OBJECTS => self.objects.push(Some(obj)), + None => {} + }, + } + } + + pub fn delete_object(&mut self, i: u32) { + if let Some(s) = self.objects.get_mut(i as usize) { + *s = None; + } + } + + /// Selects an object by index; indices with the high bit set are stock objects. + pub fn select(&mut self, i: u32) { + if i & STOCK != 0 { + return self.select_stock(i & !STOCK); + } + match self.objects.get(i as usize).copied().flatten() { + Some(Obj::Pen(p)) => self.pen = p, + Some(Obj::Brush(b)) => self.brush = b, + Some(Obj::Other) | None => {} + } + } + + fn select_stock(&mut self, i: u32) { + let white = [255, 255, 255, 255]; + let black = [0, 0, 0, 255]; + let gray = |v: u8| Brush { style: BS_SOLID, color: [v, v, v, 255] }; + match i { + 0 | 18 => self.brush = Brush { style: BS_SOLID, color: white }, + 1 => self.brush = gray(192), + 2 => self.brush = gray(128), + 3 => self.brush = gray(64), + 4 => self.brush = Brush { style: BS_SOLID, color: black }, + 5 => self.brush = Brush { style: BS_NULL, color: black }, + 6 => self.pen = Pen { style: 0, width: 0.0, color: white }, + 7 | 19 => self.pen = Pen { style: 0, width: 0.0, color: black }, + 8 => self.pen = Pen { style: PS_NULL, width: 0.0, color: black }, + _ => {} + } + } + + fn map_op(&self, op: Op) -> Op { + match op { + Op::M(p) => Op::M(self.map.device(p)), + Op::L(p) => Op::L(self.map.device(p)), + Op::C(a, b, c) => Op::C(self.map.device(a), self.map.device(b), self.map.device(c)), + Op::Z => Op::Z, + } + } + + fn fill_color(&self) -> Option<[u8; 4]> { + (self.brush.style == BS_SOLID).then_some(self.brush.color) + } + + fn stroke_pen(&self) -> Option<([u8; 4], f64)> { + if (self.pen.style & PS_STYLE_MASK) == PS_NULL { + return None; + } + // A cosmetic pen (width 0) stays 0: the renderers draw that as a hairline. + let w = if self.pen.width == 0.0 { 0.0 } else { self.pen.width.abs() * self.map.scale() }; + Some((self.pen.color, w)) + } + + fn push_path_ops(&mut self, ops: impl IntoIterator) { + for op in ops { + if self.path.len() >= MAX_PATH_OPS { + return; + } + self.path.push(op); + } + } + + /// Draws logical-space ops with the current pen (`stroke`) and brush (`fill`), or appends them to + /// the open path bracket, where fill and stroke wait for FILLPATH/STROKEPATH. + pub fn shape(&mut self, ops: &[Op], fill: bool, stroke: bool) { + let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); + if self.in_path { + return self.push_path_ops(dev); + } + if self.nop { + return; + } + let fill = if fill { self.fill_color() } else { None }; + let stroke = if stroke { self.stroke_pen() } else { None }; + if (fill.is_none() && stroke.is_none()) || dev.len() < 2 { + return; + } + let even_odd = self.even_odd; + self.push(Raw::Path { ops: dev, fill, stroke, even_odd }); + } + + pub fn move_to(&mut self, p: P) { + if self.in_path { + self.push_path_ops([Op::M(self.map.device(p))]); + } + self.pos = p; + } + + /// LINETO from the current point, then moves the current point to `p`. + pub fn line_to(&mut self, p: P) { + self.line_chain(&[p], false, true); + } + + /// Lines from the current point through `pts` (POLYLINETO), or a polyline from the current point. + pub fn line_chain(&mut self, pts: &[P], fill: bool, stroke: bool) { + // Inside an open bracket the path already ends at the current point, so no MoveTo is needed. + let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { vec![Op::M(self.pos)] }; + ops.extend(pts.iter().map(|p| Op::L(*p))); + if let Some(last) = pts.last() { + self.pos = *last; + } + self.shape(&ops, fill, stroke); + } + + /// POLYBEZIER: cubic segments that start a new figure at `from`. The current point is unchanged. + pub fn bezier(&mut self, from: P, pts: &[P]) { + let mut ops = vec![Op::M(from)]; + ops.extend(cubics(pts)); + self.shape(&ops, false, true); + } + + /// POLYBEZIERTO: cubic segments from the current point through groups of three control or end + /// points; the current point moves to the last point. Inside an open bracket the path already + /// ends at the current point, so no MoveTo is needed. + pub fn bezier_to(&mut self, pts: &[P]) { + let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { vec![Op::M(self.pos)] }; + ops.extend(cubics(pts)); + if let Some(last) = pts.last() { + self.pos = *last; + } + self.shape(&ops, false, true); + } + + pub fn poly(&mut self, pts: &[P], close: bool, fill: bool, stroke: bool) { + let Some(first) = pts.first() else { return }; + let mut ops = vec![Op::M(*first)]; + ops.extend(pts.iter().skip(1).map(|p| Op::L(*p))); + if close { + ops.push(Op::Z); + } + self.shape(&ops, fill, stroke); + } + + pub fn begin_path(&mut self) { + self.in_path = true; + self.path.clear(); + } + + pub fn end_path(&mut self) { + self.in_path = false; + } + + pub fn close_figure(&mut self) { + if self.in_path { + self.push_path_ops([Op::Z]); + } + } + + /// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it. + pub fn paint_path(&mut self, fill: bool, stroke: bool) { + let ops = std::mem::take(&mut self.path); + if self.nop { + return; + } + let fill = if fill { self.fill_color() } else { None }; + let stroke = if stroke { self.stroke_pen() } else { None }; + if ops.len() < 2 || (fill.is_none() && stroke.is_none()) { + return; + } + let even_odd = self.even_odd; + self.push(Raw::Path { ops, fill, stroke, even_odd }); + } + + /// Fills a logical rectangle `[l, t, r, b]` with the current brush (nothing when it is not solid). + pub fn fill_brush_rect(&mut self, rect: [f64; 4]) { + if let Some(color) = self.fill_color() { + self.fill_rect_with(rect, color); + } + } + + /// Fills a logical rectangle `[l, t, r, b]` with a solid colour (ROP fills such as BLACKNESS). + pub fn fill_rect_with(&mut self, [l, t, r, b]: [f64; 4], color: [u8; 4]) { + let dev: Vec = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect(); + self.push(Raw::Path { ops: dev, fill: Some(color), stroke: None, even_odd: true }); + } + + /// Whether a raster operation draws without a source bitmap (PATCOPY, BLACKNESS, WHITENESS). + pub fn sourceless(rop: u32) -> bool { + matches!(rop, PATCOPY | BLACKNESS | WHITENESS) + } + + /// Fills a logical rectangle `[x, y, w, h]` for a source-less raster operation. PATCOPY uses the + /// brush (when solid); the others fill black or white. Other operations are not handled here. + pub fn fill_rop(&mut self, [x, y, w, h]: [f64; 4], rop: u32) { + let rect = [x, y, x + w, y + h]; + match rop { + PATCOPY => self.fill_brush_rect(rect), + BLACKNESS => self.fill_rect_with(rect, [0, 0, 0, 255]), + WHITENESS => self.fill_rect_with(rect, [255, 255, 255, 255]), + _ => {} + } + } + + /// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the + /// bitmap, as the mapping does. + pub fn bitmap(&mut self, rect: [f64; 4], mut img: Rgba) { + let [x, y, w, h] = rect; + let a = self.map.device((x, y)); + let b = self.map.device((x + w, y + h)); + crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1); + let dev = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]; + self.push(Raw::Bitmap { rect: dev, img }); + } +} + +/// Cubic segments from groups of three points (control, control, end). A trailing partial group is +/// dropped. +fn cubics(pts: &[P]) -> Vec { + let mut ops = Vec::with_capacity(pts.len() / 3); + let mut it = pts.iter().copied(); + while let (Some(a), Some(b), Some(e)) = (it.next(), it.next(), it.next()) { + ops.push(Op::C(a, b, e)); + } + ops +} diff --git a/crates/metafile/src/dib.rs b/crates/metafile/src/dib.rs new file mode 100644 index 00000000..55cf091a --- /dev/null +++ b/crates/metafile/src/dib.rs @@ -0,0 +1,259 @@ +//! Device-independent bitmaps. BITMAPINFOHEADER DIBs at 1/4/8/16/24/32 bpp with BI_RGB or BI_BITFIELDS +//! decode to RGBA8 rows, top-down. Compressed (RLE, JPEG, PNG) and V4/V5-only features are not decoded. + +use crate::{MAX_PIXELS, bytes::Bytes}; + +const BI_RGB: u32 = 0; +const BI_BITFIELDS: u32 = 3; +/// Most palette entries a DIB can carry (the colour table never exceeds 2^8). +const MAX_PALETTE: usize = 256; +const ROP_SRCAND: u32 = 0x0088_00C6; +const ROP_SRCPAINT: u32 = 0x00EE_0086; +const ROP_SRCINVERT: u32 = 0x0066_0046; + +/// A decoded picture. `bottom_up` records the DIB's native row order, which source rectangles use. +pub(crate) struct Rgba { + pub w: usize, + pub h: usize, + pub bottom_up: bool, + pub px: Vec, +} + +/// How a decoded bitmap combines with the destination. +#[derive(Clone, Copy)] +pub(crate) enum Blend { + /// A raster operation (BITBLT, STRETCHDIB and friends). + Rop(u32), + /// EMR_ALPHABLEND: a constant alpha (0..=255), times the per-pixel alpha when `per_pixel` is set + /// (AC_SRC_ALPHA: the source colours are premultiplied by it). + Alpha { constant: u8, per_pixel: bool }, +} + +impl Blend { + /// Whether the decoder must keep the 32-bit alpha byte of each pixel. + pub fn per_pixel(self) -> bool { + matches!(self, Blend::Alpha { per_pixel: true, .. }) + } +} + +struct Header { + w: usize, + h: usize, + top_down: bool, + bpp: usize, + masks: [u32; 3], + pal_off: usize, + pal_n: usize, +} + +impl Header { + fn read(info: Bytes) -> Option
{ + let size = info.u32(0)? as usize; + let w = info.i32(4)?; + let h = info.i32(8)?; + let bpp = info.u16(14)? as usize; + let comp = info.u32(16)?; + let clr_used = info.u32(32)? as usize; + if size < 40 || w <= 0 || h == 0 || !matches!(bpp, 1 | 4 | 8 | 16 | 24 | 32) || !matches!(comp, BI_RGB | BI_BITFIELDS) { + return None; + } + let masks = if comp == BI_BITFIELDS { + [info.u32(40)?, info.u32(44)?, info.u32(48)?] + } else if bpp == 16 { + [0x7C00, 0x03E0, 0x001F] + } else { + [0xFF_0000, 0xFF00, 0xFF] + }; + let masks_len = if comp == BI_BITFIELDS && size == 40 { 12 } else { 0 }; + let pal_off = size.checked_add(masks_len)?; + // Indexed formats take 2^bpp entries when clrUsed is 0. Deeper formats carry only the + // entries clrUsed names (WMF DIBs can have them), still capped. + let pal_n = match (bpp <= 8, clr_used) { + (true, 0) => 1usize << bpp, + (true, n) => n.min(1usize << bpp), + (false, n) => n.min(MAX_PALETTE), + }; + Some(Header { w: w as usize, h: h.unsigned_abs() as usize, top_down: h < 0, bpp, masks, pal_off, pal_n }) + } +} + +/// Byte length of the info part (header, masks, palette) of a DIB, so the pixel bits follow it. +pub(crate) fn info_len(dib: Bytes) -> Option { + let h = Header::read(dib)?; + h.pal_off.checked_add(h.pal_n.checked_mul(4)?) +} + +/// Decodes a DIB given as its info part (header plus masks and palette) and its pixel bits. The +/// 32-bit alpha byte is kept only when `keep_alpha` is set (per-pixel alpha blends); otherwise pixels +/// are opaque. +pub(crate) fn decode(info: Bytes, bits: Bytes, keep_alpha: bool) -> Option { + let hd = Header::read(info)?; + let pixels = hd.w.checked_mul(hd.h)?; + if pixels == 0 || pixels > MAX_PIXELS { + return None; + } + let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4; + let data = bits.slice(0, stride.checked_mul(hd.h)?)?; + let pal = info.slice(hd.pal_off, hd.pal_n * 4)?; + let mut px = vec![0u8; pixels * 4]; + for y in 0..hd.h { + let src_row = if hd.top_down { y } else { hd.h - 1 - y }; + let row = src_row * stride; + for x in 0..hd.w { + let p = pixel(data, row, x, &hd, pal, keep_alpha)?; + let o = (y * hd.w + x) * 4; + px.get_mut(o..o + 4)?.copy_from_slice(&p); + } + } + Some(Rgba { w: hd.w, h: hd.h, bottom_up: !hd.top_down, px }) +} + +fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> { + let [m_r, m_g, m_b] = hd.masks; + let [r, g, b] = match hd.bpp { + 1 => { + let b = *data.get(row + x / 8)?; + palette(pal, usize::from((b >> (7 - x % 8)) & 1)) + } + 4 => { + let b = *data.get(row + x / 2)?; + palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F })) + } + 8 => palette(pal, usize::from(*data.get(row + x)?)), + 24 => { + let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?; + [r, g, b] + } + 16 => { + let o = row + x * 2; + let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?)); + [field(v, m_r), field(v, m_g), field(v, m_b)] + } + _ => { + let o = row + x * 4; + let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?); + let a = if keep_alpha { (v >> 24) as u8 } else { 255 }; + return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]); + } + }; + Some([r, g, b, 255]) +} + +/// Palette entries are BGRX quads; an index past the table reads as black rather than failing the image. +fn palette(pal: &[u8], i: usize) -> [u8; 3] { + pal.get(i * 4..i * 4 + 4).and_then(|c| <[u8; 4]>::try_from(c).ok()).map_or([0, 0, 0], |c| [c[2], c[1], c[0]]) +} + +/// Extracts one channel from a masked pixel value and scales it to 8 bits. +fn field(v: u32, mask: u32) -> u8 { + if mask == 0 { + return 0; + } + let shift = mask.trailing_zeros(); + let max = u64::from(mask >> shift); + let val = u64::from((v & mask) >> shift); + ((val * 255 + max / 2) / max) as u8 +} + +/// Clips a source rectangle `src` = `[x, y, w, h]` to the bitmap and returns the cropped pixels (top-down) +/// with the part of the destination `dst` that they fill. `src` is in DIB pixel coordinates (`y` counted +/// from the DIB's origin, so bottom-up images count from the bottom); a non-positive source size means the +/// whole bitmap. Clipping shrinks the destination by the same ratio and shifts it by the clipped-off part, +/// so the rest of the image keeps its place. A negative destination extent is kept (the caller mirrors). +pub(crate) fn clip_blit(img: &Rgba, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> { + let (iw, ih) = (img.w as i64, img.h as i64); + let [sx, sy, sw, sh] = if src[2] > 0 && src[3] > 0 { src } else { [0, 0, iw, ih] }; + // The request's top row in top-down order. + let top = if img.bottom_up { ih.saturating_sub(sy).saturating_sub(sh) } else { sy }; + let (x0, x1) = (sx.max(0), sx.saturating_add(sw).min(iw)); + let (r0, r1) = (top.max(0), top.saturating_add(sh).min(ih)); + if x1 <= x0 || r1 <= r0 { + return None; + } + let (kx, ky) = (dst[2] / sw as f64, dst[3] / sh as f64); + let out = [dst[0] + (x0 - sx) as f64 * kx, dst[1] + (r0 - top) as f64 * ky, (x1 - x0) as f64 * kx, (r1 - r0) as f64 * ky]; + let (x0, x1, r0, r1) = (x0 as usize, x1 as usize, r0 as usize, r1 as usize); + let (ow, oh) = (x1 - x0, r1 - r0); + let mut px = Vec::with_capacity(ow * oh * 4); + for row in r0..r1 { + let s = (row * img.w + x0) * 4; + px.extend_from_slice(img.px.get(s..s + ow * 4)?); + } + Some((Rgba { w: ow, h: oh, bottom_up: false, px }, out)) +} + +/// Applies a blend to decoded pixels before they are drawn. The picture has no destination pixels, so +/// raster operations are drawn as they look over white paper: SRCAND (destination AND source) leaves +/// white source pixels out and draws the rest; SRCPAINT and SRCINVERT leave black ones out. Other +/// operations draw the source as copied. Constant alpha scales every pixel's alpha, after a +/// per-pixel (premultiplied) source is made straight. +pub(crate) fn prepare(img: &mut Rgba, blend: Blend) { + match blend { + Blend::Rop(ROP_SRCAND) => clear_where(img, [255, 255, 255]), + Blend::Rop(ROP_SRCPAINT | ROP_SRCINVERT) => clear_where(img, [0, 0, 0]), + Blend::Rop(_) => {} + Blend::Alpha { constant, per_pixel } => { + for p in img.px.as_chunks_mut::<4>().0 { + if per_pixel { + unpremultiply(p); + } + p[3] = (u16::from(p[3]) * u16::from(constant) / 255) as u8; + } + } + } +} + +/// Makes the pixels of colour `rgb` transparent: where a raster operation keeps the destination. +fn clear_where(img: &mut Rgba, rgb: [u8; 3]) { + for p in img.px.as_chunks_mut::<4>().0 { + if p[..3] == rgb { + p[3] = 0; + } + } +} + +/// AC_SRC_ALPHA sources are premultiplied; the model holds straight RGBA, so divide the colour by the +/// alpha (rounded, capped at 255). A transparent pixel keeps its colour bytes. +fn unpremultiply(p: &mut [u8; 4]) { + let a = u32::from(p[3]); + if a == 0 || a == 255 { + return; + } + for c in &mut p[..3] { + *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; + } +} + +/// Mirrors a decoded image left-right and/or top-bottom. Rows come from the buffer as chunks, so a +/// short buffer flips only the rows it has. +pub(crate) fn flip(img: &mut Rgba, horizontal: bool, vertical: bool) { + let (w, h) = (img.w, img.h); + if w == 0 || h == 0 { + return; + } + let row = w * 4; + if horizontal { + for line in img.px.chunks_exact_mut(row) { + for x in 0..w / 2 { + for c in 0..4 { + line.swap(x * 4 + c, (w - 1 - x) * 4 + c); + } + } + } + } + if vertical { + // Rows the buffer actually holds: a short buffer flips only those. + let n = h.min(img.px.len() / row); + for y in 0..n / 2 { + swap_rows(&mut img.px, y * row, (n - 1 - y) * row, row); + } + } +} + +/// Swaps the `len` bytes at `a` with those at `b` (`a < b`); does nothing when either is out of range. +fn swap_rows(px: &mut [u8], a: usize, b: usize, len: usize) { + let Some((head, tail)) = px.split_at_mut_checked(b) else { return }; + if let (Some(top), Some(bottom)) = (head.get_mut(a..a.saturating_add(len)), tail.get_mut(..len)) { + top.swap_with_slice(bottom); + } +} diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs new file mode 100644 index 00000000..027cfae9 --- /dev/null +++ b/crates/metafile/src/emf.rs @@ -0,0 +1,315 @@ +//! EMF reader (MS-EMF). Records are (type, size in bytes) followed by a payload; offsets inside a +//! record (bitmap info and bits) are relative to the record start, so each handler reads the record slice. + +use crate::bytes::Bytes; +use crate::canvas::{BS_NULL, Brush, Canvas, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref}; +use crate::dib::{self, Blend}; +use crate::geom::{self, ArcKind, P}; +use crate::{Error, Frame, MAX_PATH_OPS}; + +const EMF_SIGNATURE: u32 = 0x464D_4520; +/// Hundredths of a millimetre to points. +const HMM_TO_PT: f64 = 72.0 / 2540.0; +/// SRCCOPY, the plain copy of a bitmap. +const SRCCOPY: u32 = 0x00CC_0020; +/// LogPenEx pen type bits; PS_COSMETIC pens are one device pixel wide. +const PS_TYPE_MASK: u32 = 0x000F_0000; +const PS_COSMETIC: u32 = 0; + +pub(crate) fn is_emf(b: Bytes) -> bool { + b.u32(0) == Some(1) && b.u32(40) == Some(EMF_SIGNATURE) +} + +/// The picture frame: `rclFrame` (0.01 mm) mapped to device units through `szlDevice` and +/// `szlMillimeters`, sized in points. Without a usable frame, the device bounds stand in (1 unit = 1 point). +pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { + if !is_emf(input) { + return Err(Error::BadHeader); + } + let hsize = input.u32(4).ok_or(Error::BadHeader)? as usize; + let bounds = rect32(input, 8).ok_or(Error::BadHeader)?; + let [l, t, r, b] = bounds; + let frame = frame_from_header(input).or_else(|| (r > l && b > t).then_some(Frame { rect: [l, t, r - l, b - t], size: None })); + let mut cv = Canvas::new(); + if let Some(px_mm) = device_px_per_mm(input) { + cv.set_device_mm(px_mm); + } + let mut parsed = 0usize; + let mut pos = hsize.max(88); + while !cv.full() { + let (Some(typ), Some(size)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u32(o))) else { break }; + let size = size as usize; + if size < 8 || !size.is_multiple_of(4) { + break; + } + let Some(rec) = input.slice(pos, size) else { break }; + if typ == 14 { + break; + } + record(&mut cv, typ, Bytes(rec)); + parsed += 1; + let Some(next) = pos.checked_add(size) else { break }; + pos = next; + } + if parsed == 0 { + return Err(Error::NoRecords); + } + Ok((cv, frame)) +} + +/// Device pixels per millimetre on each axis, from `szlDevice` (72) and `szlMillimeters` (80). +fn device_px_per_mm(input: Bytes) -> Option<(f64, f64)> { + let (dw, dh) = (input.i32(72)?, input.i32(76)?); + let (mw, mh) = (input.i32(80)?, input.i32(84)?); + (dw > 0 && dh > 0 && mw > 0 && mh > 0).then(|| (f64::from(dw) / f64::from(mw), f64::from(dh) / f64::from(mh))) +} + +/// The frame from `rclFrame` (offset 24), `szlDevice` (72) and `szlMillimeters` (80) in the header. +fn frame_from_header(input: Bytes) -> Option { + let [fl, ft, fr, fb] = rect32(input, 24)?; + if fr <= fl || fb <= ft { + return None; + } + // Device units per hundredth of a millimetre. + let (px_mm_x, px_mm_y) = device_px_per_mm(input)?; + let (sx, sy) = (px_mm_x / 100.0, px_mm_y / 100.0); + let rect = [fl * sx, ft * sy, (fr - fl) * sx, (fb - ft) * sy]; + Some(Frame { rect, size: Some(((fr - fl) * HMM_TO_PT, (fb - ft) * HMM_TO_PT)) }) +} + +fn record(cv: &mut Canvas, typ: u32, r: Bytes) { + if cv.full() { + return; + } + let _ = match typ { + 9 => xy32(r, 8).map(|p| cv.set_window_ext(p)), + 10 => xy32(r, 8).map(|p| cv.set_window_org(p)), + 11 => xy32(r, 8).map(|p| cv.set_viewport_ext(p)), + 12 => xy32(r, 8).map(|p| cv.set_viewport_org(p)), + 17 => r.u32(8).map(|m| cv.set_map_mode(m)), + 19 => r.u32(8).map(|m| cv.set_polyfill(m)), + 20 => r.u32(8).map(|m| cv.set_rop2(m)), + 33 => { + cv.save_dc(); + Some(()) + } + 34 => r.i32(8).map(|n| cv.restore_dc(n)), + 35 => xform(r, 8).map(|x| cv.modify_world(x, 4)), + 36 => xform(r, 8).zip(r.u32(32)).map(|(x, m)| cv.modify_world(x, m)), + 37 => r.u32(8).map(|i| cv.select(i)), + 38 => create_pen(cv, r), + 95 => create_ext_pen(cv, r), + 39 => create_brush(cv, r), + 40 => r.u32(8).map(|i| cv.delete_object(i)), + 27 => xy32(r, 8).map(|p| cv.move_to(p)), + 54 => xy32(r, 8).map(|p| cv.line_to(p)), + 42 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)), + 43 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)), + 44 => { + let (cw, ch) = (r.i32(24), r.i32(28)); + let (Some(cw), Some(ch)) = (cw, ch) else { return }; + rect32(r, 8).map(|[l, t, rr, b]| { + cv.shape(&geom::round_rect(l, t, rr, b, f64::from(cw), f64::from(ch)), true, true); + }) + } + 45..=47 => arc32(r).map(|(bounds, s, e)| { + let kind = match typ { + 45 => ArcKind::Arc, + 46 => ArcKind::Chord, + _ => ArcKind::Pie, + }; + let [l, t, rr, b] = bounds; + cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true); + }), + 4 | 87 => counted(r, typ == 4).map(|pts| cv.poly(&pts, false, false, true)), + 3 | 86 => counted(r, typ == 3).map(|pts| cv.poly(&pts, true, true, true)), + 6 | 89 => counted(r, typ == 6).map(|pts| cv.line_chain(&pts, false, true)), + // POLYBEZIER starts its own figure at the first point and leaves the current point alone; + // POLYBEZIERTO continues from the current point. + 2 | 85 => counted(r, typ == 2).and_then(|pts| { + let from = *pts.first()?; + cv.bezier(from, pts.get(1..)?); + Some(()) + }), + 5 | 88 => counted(r, typ == 5).map(|pts| cv.bezier_to(&pts)), + 7 | 90 => poly_groups(r, typ == 7).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, false), false, true)), + 8 | 91 => poly_groups(r, typ == 8).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, true), true, true)), + 59 => { + cv.begin_path(); + Some(()) + } + 60 => { + cv.end_path(); + Some(()) + } + 61 => { + cv.close_figure(); + Some(()) + } + 62 => { + cv.paint_path(true, false); + Some(()) + } + 63 => { + cv.paint_path(true, true); + Some(()) + } + 64 => { + cv.paint_path(false, true); + Some(()) + } + 76 | 77 => bitblt(cv, r, typ == 77), + 80 => set_dibits(cv, r), + 81 => stretch_dibits(cv, r), + 114 => alphablend(cv, r), + _ => Some(()), + }; +} + +/// Two little-endian i32 values at `off`, as a point. +fn xy32(r: Bytes, off: usize) -> Option

{ + Some((f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?))) +} + +/// RECTL (left, top, right, bottom) as i32 values widened to f64. +fn rect32(r: Bytes, off: usize) -> Option<[f64; 4]> { + Some([f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?), f64::from(r.i32(off + 8)?), f64::from(r.i32(off + 12)?)]) +} + +/// An i32 field as f64; 0 past the end. +fn i32s(r: Bytes, off: usize) -> f64 { + r.i32(off).map_or(0.0, f64::from) +} + +/// XFORM: six f32 values in M11, M12, M21, M22, Dx, Dy order. +fn xform(r: Bytes, off: usize) -> Option { + let mut x = [0.0f64; 6]; + for (i, v) in x.iter_mut().enumerate() { + *v = f64::from(r.f32(off + i * 4)?); + } + Some(x) +} + +/// ARC, CHORD, PIE: bounds at 8, start point at 24, end point at 32. +fn arc32(r: Bytes) -> Option<([f64; 4], P, P)> { + Some((rect32(r, 8)?, xy32(r, 24)?, xy32(r, 32)?)) +} + +/// A point count from a record, rejected when it exceeds the path budget (before any allocation). +fn budget(n: u32) -> Option { + let n = n as usize; + (n <= MAX_PATH_OPS).then_some(n) +} + +/// Point count at 24, then the points (32-bit or 16-bit) at 28. +fn counted(r: Bytes, wide: bool) -> Option> { + let n = budget(r.u32(24)?)?; + r.tail(28)?.points(0, n, wide) +} + +/// Subpath count at 24, total point count at 28, per-subpath counts at 32, then the points. +fn poly_groups(r: Bytes, wide: bool) -> Option<(Vec, Vec

)> { + let n = budget(r.u32(24)?)?; + // The record must hold the n counts before anything is allocated for them. + let raw = r.slice(32, n.checked_mul(4)?)?; + let counts: Vec = raw.as_chunks::<4>().0.iter().map(|c| u32::from_le_bytes(*c) as usize).collect(); + let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?; + let total = budget(u32::try_from(total).ok()?)?; + let pts = r.tail(n.checked_mul(4)?.checked_add(32)?)?.points(0, total, wide)?; + Some((counts, pts)) +} + +/// EMR_CREATEPEN: handle, LogPen (style, width x/y, colour). +fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> { + let slot = r.u32(8)? as usize; + let style = r.u32(12)?; + let width = r.i32(16)?; + let color = r.u32(24)?; + cv.add_object(Some(slot), Obj::Pen(Pen { style, width: f64::from(width), color: colorref(color) })); + Some(()) +} + +/// EMR_EXTCREATEPEN: handle, bitmap offsets (unused), LogPenEx (style, width, brush style, colour). +/// A cosmetic pen's width is in device pixels (always 1), so it is a hairline; a BS_NULL brush draws +/// no line at all. +fn create_ext_pen(cv: &mut Canvas, r: Bytes) -> Option<()> { + let slot = r.u32(8)? as usize; + let (style, width, brush, color) = (r.u32(28)?, r.u32(32)?, r.u32(36)?, r.u32(40)?); + let width = if style & PS_TYPE_MASK == PS_COSMETIC { 0.0 } else { f64::from(width) }; + let style = if brush == BS_NULL { PS_NULL } else { style & PS_STYLE_MASK }; + cv.add_object(Some(slot), Obj::Pen(Pen { style, width, color: colorref(color) })); + Some(()) +} + +/// EMR_CREATEBRUSHINDIRECT: handle, LogBrush32 (style, colour, hatch). +fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> { + let slot = r.u32(8)? as usize; + let style = r.u32(12)?; + let color = r.u32(16)?; + cv.add_object(Some(slot), Obj::Brush(Brush { style, color: colorref(color) })); + Some(()) +} + +/// BITBLT (76) and STRETCHBLT (77): dest at 24..40, rop at 40, source x/y at 44, bmi at 84, bits at +/// 92; STRETCHBLT adds the source w/h at 100. A source-less rop fills the destination instead. +fn bitblt(cv: &mut Canvas, r: Bytes, stretch: bool) -> Option<()> { + let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)]; + let rop = r.u32(40)?; + if Canvas::sourceless(rop) { + cv.fill_rop(dst, rop); + return Some(()); + } + let (sw, sh) = if stretch { (i32s(r, 100), i32s(r, 104)) } else { (dst[2], dst[3]) }; + let src = [i32s(r, 44), i32s(r, 48), sw, sh]; + blit(cv, r, dst, src, (84, 92), Blend::Rop(rop)) +} + +/// ALPHABLEND: laid out like STRETCHBLT, with a BLENDFUNCTION at 40. Constant alpha at 42 applies to +/// every pixel; AlphaFormat at 43 with AC_SRC_ALPHA (1) also uses the per-pixel alpha. +fn alphablend(cv: &mut Canvas, r: Bytes) -> Option<()> { + let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)]; + let constant = *r.0.get(42)?; + let per_pixel = (*r.0.get(43)? & 0x01) != 0; + let src = [i32s(r, 44), i32s(r, 48), i32s(r, 100), i32s(r, 104)]; + blit(cv, r, dst, src, (84, 92), Blend::Alpha { constant, per_pixel }) +} + +/// STRETCHDIBITS: dest x/y at 24, src x/y/w/h at 32..48, bmi offset/size at 48, bits at 56, rop at 68, +/// dest w/h at 72. +fn stretch_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> { + let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 72), i32s(r, 76)]; + let src = [i32s(r, 32), i32s(r, 36), i32s(r, 40), i32s(r, 44)]; + let rop = r.u32(68)?; + blit(cv, r, dst, src, (48, 56), Blend::Rop(rop)) +} + +/// SETDIBITSTODEVICE (MS-EMF 2.3.1.5): dest x/y at 24, src x/y at 32, size (cxSrc, cySrc) at 40, bmi +/// at 48, bits at 56, start scan at 68. The image keeps its own size; the bits begin at scan line +/// `iStartScan`, so the source y counts from there. +fn set_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> { + let (cx, cy) = (i32s(r, 40), i32s(r, 44)); + if cx <= 0.0 || cy <= 0.0 { + return None; + } + let dst = [i32s(r, 24), i32s(r, 28), cx, cy]; + let src = [i32s(r, 32), i32s(r, 36) - f64::from(r.u32(68)?), cx, cy]; + blit(cv, r, dst, src, (48, 56), Blend::Rop(SRCCOPY)) +} + +/// Decodes the bitmap at `at` = (offset of the bmi offset/length pair, offset of the bits pair), both +/// inside the record, and stretches the source rectangle `src` into `dst`. Both are `[x, y, w, h]`; a +/// non-positive source size means the whole bitmap. `blend` decides how it combines. +fn blit(cv: &mut Canvas, r: Bytes, dst: [f64; 4], src: [f64; 4], at: (usize, usize), blend: Blend) -> Option<()> { + let (bo, bl) = (r.u32(at.0)? as usize, r.u32(at.0 + 4)? as usize); + let (po, pl) = (r.u32(at.1)? as usize, r.u32(at.1 + 4)? as usize); + if pl == 0 { + return None; + } + let info = Bytes(r.slice(bo, bl)?); + let pixels = Bytes(r.slice(po, pl)?); + let mut img = dib::decode(info, pixels, blend.per_pixel())?; + dib::prepare(&mut img, blend); + let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?; + cv.bitmap(dst, img); + Some(()) +} diff --git a/crates/metafile/src/geom.rs b/crates/metafile/src/geom.rs new file mode 100644 index 00000000..8063c730 --- /dev/null +++ b/crates/metafile/src/geom.rs @@ -0,0 +1,118 @@ +//! Path operations in `f64` and the shape builders that turn WMF/EMF rectangles, ellipses and arcs +//! into cubic Béziers (the classic 4/3·tan(Δ/4) approximation, at most 90° per piece). + +use std::f64::consts::{FRAC_PI_2, PI, TAU}; + +pub(crate) type P = (f64, f64); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Op { + M(P), + L(P), + C(P, P, P), + Z, +} + +pub(crate) fn rect(l: f64, t: f64, r: f64, b: f64) -> Vec { + vec![Op::M((l, t)), Op::L((r, t)), Op::L((r, b)), Op::L((l, b)), Op::Z] +} + +/// Corner radii are half the EMF/WMF corner ellipse size, clamped so the corners never overlap. +pub(crate) fn round_rect(l: f64, t: f64, r: f64, b: f64, ew: f64, eh: f64) -> Vec { + // Reversed corners (right < left, bottom < top) describe the same rectangle. + let (l, r) = (l.min(r), l.max(r)); + let (t, b) = (t.min(b), t.max(b)); + let rx = (ew / 2.0).abs().min((r - l) / 2.0); + let ry = (eh / 2.0).abs().min((b - t) / 2.0); + let mut ops = vec![Op::M((l + rx, t)), Op::L((r - rx, t))]; + ops.extend(arc(r - rx, t + ry, rx, ry, -FRAC_PI_2, FRAC_PI_2)); + ops.push(Op::L((r, b - ry))); + ops.extend(arc(r - rx, b - ry, rx, ry, 0.0, FRAC_PI_2)); + ops.push(Op::L((l + rx, b))); + ops.extend(arc(l + rx, b - ry, rx, ry, FRAC_PI_2, FRAC_PI_2)); + ops.push(Op::L((l, t + ry))); + ops.extend(arc(l + rx, t + ry, rx, ry, PI, FRAC_PI_2)); + ops.push(Op::Z); + ops +} + +pub(crate) fn ellipse(l: f64, t: f64, r: f64, b: f64) -> Vec { + let (cx, cy, rx, ry) = center(l, t, r, b); + let mut ops = vec![Op::M((cx + rx, cy))]; + ops.extend(arc(cx, cy, rx, ry, 0.0, TAU)); + ops.push(Op::Z); + ops +} + +/// Which closed or open shape an ARC/CHORD/PIE record draws. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum ArcKind { + Arc, + Chord, + Pie, +} + +/// ARC, CHORD and PIE: the arc runs counter-clockwise on screen from the start point to the end point. +pub(crate) fn arc_shape(l: f64, t: f64, r: f64, b: f64, s: P, e: P, kind: ArcKind) -> Vec { + let (cx, cy, rx, ry) = center(l, t, r, b); + let ang = |p: P| ((p.1 - cy) / ry.max(f64::MIN_POSITIVE)).atan2((p.0 - cx) / rx.max(f64::MIN_POSITIVE)); + let (a0, a1) = (ang(s), ang(e)); + let mut d = (a0 - a1).rem_euclid(TAU); + if d < 1e-12 { + d = TAU; + } + let sweep = -d; + let start = (cx + rx * a0.cos(), cy + ry * a0.sin()); + let mut ops = if kind == ArcKind::Pie { vec![Op::M((cx, cy)), Op::L(start)] } else { vec![Op::M(start)] }; + ops.extend(arc(cx, cy, rx, ry, a0, sweep)); + match kind { + ArcKind::Arc => {} + ArcKind::Chord => ops.push(Op::Z), + ArcKind::Pie => ops.extend([Op::L((cx, cy)), Op::Z]), + } + ops +} + +fn center(l: f64, t: f64, r: f64, b: f64) -> (f64, f64, f64, f64) { + ((l + r) / 2.0, (t + b) / 2.0, (r - l).abs() / 2.0, (b - t).abs() / 2.0) +} + +/// Cubic pieces along an ellipse from parameter `a0` over signed `sweep` radians. The current point +/// must already be at `(cx + rx cos a0, cy + ry sin a0)`. +fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, sweep: f64) -> Vec { + let n = ((sweep.abs() / FRAC_PI_2).ceil() as usize).clamp(1, 8); + let step = sweep / n as f64; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let pt = |t: f64| (cx + rx * t.cos(), cy + ry * t.sin()); + let mut ops = Vec::with_capacity(n); + for i in 0..n { + let t0 = a0 + step * i as f64; + let t1 = t0 + step; + let (p0, p3) = (pt(t0), pt(t1)); + let c1 = (p0.0 - k * rx * t0.sin(), p0.1 + k * ry * t0.cos()); + let c2 = (p3.0 + k * rx * t1.sin(), p3.1 - k * ry * t1.cos()); + ops.push(Op::C(c1, c2, p3)); + } + ops +} + +/// Consecutive subpaths: `counts[i]` points each, taken in order from `pts`. Counts that run past the +/// point list are cut short rather than failing the whole record. +pub(crate) fn subpaths(pts: &[P], counts: &[usize], close: bool) -> Vec { + let mut ops = Vec::with_capacity(pts.len() + counts.len()); + let mut at = 0usize; + for &c in counts { + let end = at.saturating_add(c).min(pts.len()); + let Some((first, rest)) = pts.get(at..end).and_then(|s| s.split_first()) else { + at = end; + continue; + }; + ops.push(Op::M(*first)); + ops.extend(rest.iter().map(|p| Op::L(*p))); + if close { + ops.push(Op::Z); + } + at = end; + } + ops +} diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs new file mode 100644 index 00000000..22002351 --- /dev/null +++ b/crates/metafile/src/lib.rs @@ -0,0 +1,208 @@ +//! DeckCraft metafiles (ported from WordCraft's `crates/metafile`): reads Windows Metafiles (WMF) +//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths and RGBA +//! bitmaps, in a picture frame with y down. +//! +//! The reader is pure Rust over byte slices (no file system), so it builds for wasm. Every read is +//! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`], +//! never a panic. +//! +//! Records follow MS-WMF and MS-EMF. Records outside the supported set are skipped silently. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod bytes; +mod canvas; +mod dib; +mod emf; +mod geom; +mod place; +mod wmf; + +#[cfg(test)] +mod tests; + +use bytes::Bytes; +use canvas::Raw; +use geom::{Op, P}; +pub use place::{MAX_BITMAP_SIDE, PlacedItem}; + +/// Items kept from one file. Past the cap the parse stops and keeps what it has. +pub(crate) const MAX_ITEMS: usize = 200_000; +/// Object table slots (WMF handles, EMF object indices). +pub(crate) const MAX_OBJECTS: usize = 4096; +/// Largest bitmap in pixels (about 40 megapixels, 160 MB of RGBA). +pub(crate) const MAX_PIXELS: usize = 40_000_000; +/// DC save-stack depth. +pub(crate) const MAX_STACK: usize = 1024; +/// Points in one open path bracket. +pub(crate) const MAX_PATH_OPS: usize = 2_000_000; +/// Bytes of path ops and pixels kept from one file (about 256 MB). +pub(crate) const MAX_BYTES: usize = 256 * 1024 * 1024; + +/// A parsed metafile. Items use coordinates in `0..width` and `0..height`, y down. +#[derive(Debug, Clone, PartialEq)] +pub struct Picture { + /// Size of the drawing in its own units: points for placeable WMF and EMF (from the frame), device + /// or logical units otherwise. + pub width: f32, + pub height: f32, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Item { + /// A filled and/or stroked path, in picture coordinates. + Path { + segs: Vec, + fill: Option<[u8; 4]>, + /// Colour and width in picture units. + stroke: Option<([u8; 4], f32)>, + even_odd: bool, + }, + /// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down. + Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec }, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Seg { + Move(f32, f32), + Line(f32, f32), + Cubic(f32, f32, f32, f32, f32, f32), + Close, +} + +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum Error { + #[error("not a WMF or EMF file")] + NotMetafile, + #[error("metafile header is truncated or invalid")] + BadHeader, + #[error("metafile has no readable records")] + NoRecords, +} + +/// The picture frame in device units (`rect` = `[x, y, w, h]`) and, when the file states one, its +/// size in picture units. +pub(crate) struct Frame { + pub rect: [f64; 4], + pub size: Option<(f64, f64)>, +} + +/// Parses a WMF (placeable or plain) or EMF file. +pub fn parse(bytes: &[u8]) -> Result { + let b = Bytes(bytes); + let (cv, frame) = if emf::is_emf(b) { + emf::parse(b)? + } else if wmf::is_wmf(b) { + wmf::parse(b)? + } else { + return Err(Error::NotMetafile); + }; + Ok(build(cv.items, frame)) +} + +/// True when the bytes start like a WMF (placeable or plain header) or an EMF. +pub fn is_metafile(bytes: &[u8]) -> bool { + let b = Bytes(bytes); + emf::is_emf(b) || wmf::is_wmf(b) +} + +/// Maps device-space output into the picture frame. Without a usable frame the frame is the bounding +/// box of the drawing, so the origin is still the top-left of what was drawn. +fn build(raw: Vec, frame: Option) -> Picture { + let frame = frame.filter(|f| f.rect.iter().all(|v| v.is_finite()) && f.rect[2] > 0.0 && f.rect[3] > 0.0); + let (rect, size) = match frame { + Some(f) => (f.rect, f.size.unwrap_or((f.rect[2], f.rect[3]))), + None => match bbox(&raw) { + Some([x0, y0, x1, y1]) => ([x0, y0, x1 - x0, y1 - y0], (x1 - x0, y1 - y0)), + None => return Picture { width: 0.0, height: 0.0, items: Vec::new() }, + }, + }; + let sx = if rect[2] > 0.0 { size.0 / rect[2] } else { 1.0 }; + let sy = if rect[3] > 0.0 { size.1 / rect[3] } else { 1.0 }; + let tf = move |p: P| ((p.0 - rect[0]) * sx, (p.1 - rect[1]) * sy); + let stroke_scale = (sx + sy) / 2.0; + // No stroke is wider than the picture's diagonal: anything wider is a mapping artefact. + let diag = size.0.hypot(size.1); + let items = raw.into_iter().filter_map(|r| match r { + Raw::Path { ops, fill, stroke, even_odd } => { + let segs = ops.iter().map(|op| seg(op, tf)).collect::>>()?; + if segs.len() < 2 { + return None; + } + let stroke = stroke.and_then(|(c, w)| Some((c, f((w * stroke_scale).min(diag))?))); + Some(Item::Path { segs, fill, stroke, even_odd }) + } + Raw::Bitmap { rect: r, img } => { + let (x, y) = tf((r[0], r[1])); + let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?]; + Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px }) + } + }); + let items = items.collect(); + Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items } +} + +fn f(v: f64) -> Option { + let v = v as f32; + v.is_finite().then_some(v) +} + +fn seg(op: &Op, tf: impl Fn(P) -> P) -> Option { + let pt = |p: P| { + let (x, y) = tf(p); + Some((f(x)?, f(y)?)) + }; + Some(match *op { + Op::M(p) => { + let (x, y) = pt(p)?; + Seg::Move(x, y) + } + Op::L(p) => { + let (x, y) = pt(p)?; + Seg::Line(x, y) + } + Op::C(a, b, c) => { + let (x1, y1) = pt(a)?; + let (x2, y2) = pt(b)?; + let (x3, y3) = pt(c)?; + Seg::Cubic(x1, y1, x2, y2, x3, y3) + } + Op::Z => Seg::Close, + }) +} + +/// Bounding box `[x0, y0, x1, y1]` over the finite points of the output. +fn bbox(raw: &[Raw]) -> Option<[f64; 4]> { + let mut b: Option<[f64; 4]> = None; + let mut grow = |p: P| { + if p.0.is_finite() && p.1.is_finite() { + b = Some(match b { + None => [p.0, p.1, p.0, p.1], + Some([x0, y0, x1, y1]) => [x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1)], + }); + } + }; + for r in raw { + match r { + Raw::Path { ops, .. } => { + for op in ops { + match *op { + Op::M(p) | Op::L(p) => grow(p), + Op::C(a, c, d) => { + grow(a); + grow(c); + grow(d); + } + Op::Z => {} + } + } + } + Raw::Bitmap { rect, .. } => { + grow((rect[0], rect[1])); + grow((rect[0] + rect[2], rect[1] + rect[3])); + } + } + } + b +} diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs new file mode 100644 index 00000000..68f07955 --- /dev/null +++ b/crates/metafile/src/place.rs @@ -0,0 +1,124 @@ +//! Placing a picture on a target (a page or a device): the fitted transform, paths as kurbo paths in +//! target units and bitmaps as target rectangles. The rasteriser and the PDF writer only paint the result. + +use kurbo::{Affine, BezPath, Rect, Shape}; + +use crate::{Item, Picture, Seg}; + +/// Largest bitmap side the renderers draw: within the PDF image limit and the rasteriser's 16-bit sides. +pub const MAX_BITMAP_SIDE: u32 = 30_000; +/// Placed coordinates past this (target units) are far off any page; such items are left out. +const MAX_COORD: f64 = 1e6; +/// Smallest placed bitmap side (target units), so the stretch onto it is never singular. +const MIN_BITMAP_SIDE: f64 = 1e-6; + +/// One item of a placed picture, in target units. +pub enum PlacedItem { + /// A filled and/or stroked path. The stroke width is in target units; 0 is a cosmetic hairline that + /// the renderer draws at its minimum width. + Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool }, + /// `Picture::items[index]`, a bitmap, stretched into `rect`. + Bitmap { index: usize, rect: Rect }, +} + +impl Picture { + /// Whether the picture has a finite, positive size, so it can be fitted into a frame. + pub fn has_size(&self) -> bool { + self.width.is_finite() && self.height.is_finite() && self.width > 0.0 && self.height > 0.0 + } + + /// The transform that fits the picture into `r` with the crop fractions `crop` (left, top, right, + /// bottom) removed first: the visible part fills `r`. None for a degenerate size. + pub fn fit(&self, r: Rect, crop: [f32; 4]) -> Option { + let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 0.95)) } else { 0.0 }); + let (w, h) = (f64::from(self.width), f64::from(self.height)); + let sx = r.width() / (w * (1.0 - cl - cr).max(0.05)); + let sy = r.height() / (h * (1.0 - ct - cb).max(0.05)); + (sx.is_finite() && sy.is_finite() && sx > 0.0 && sy > 0.0) + .then(|| Affine::translate((r.x0 - cl * w * sx, r.y0 - ct * h * sy)) * Affine::scale_non_uniform(sx, sy)) + } + + /// The items placed into `r` with `crop` applied. Items that are not finite or reach past ±1e6 + /// target units are left out (they are far off any page), as are bitmaps with no area. Stroke + /// widths are scaled to target units (along the mean axis scale, so a non-uniform fit keeps + /// strokes even). + pub fn place(&self, r: Rect, crop: [f32; 4]) -> Vec { + let Some(tf) = self.fit(r, crop) else { return Vec::new() }; + let c = tf.as_coeffs(); + let k = (c[0].abs() + c[3].abs()) / 2.0; + let mut out = Vec::with_capacity(self.items.len()); + for (index, item) in self.items.iter().enumerate() { + match item { + Item::Path { segs, fill, stroke, even_odd } => { + let path = tf * bez(segs); + if in_range(path.bounding_box()) { + let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k)); + out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd }); + } + } + Item::Bitmap { rect: [x, y, w, h], .. } => { + let (x0, y0) = (f64::from(*x), f64::from(*y)); + let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h))); + if in_range(rect) && rect.width() > MIN_BITMAP_SIDE && rect.height() > MIN_BITMAP_SIDE { + out.push(PlacedItem::Bitmap { index, rect }); + } + } + } + } + out + } +} + +impl Item { + /// Takes a bitmap's pixels out of the item (its geometry stays) and returns them as (width, height, + /// RGBA) when a renderer can draw them: both sides in 1..=[`MAX_BITMAP_SIDE`] and exactly that many + /// pixels. None for paths and for bitmaps that cannot be drawn. + pub fn take_pixels(&mut self) -> Option<(u32, u32, Vec)> { + let Item::Bitmap { width, height, rgba, .. } = self else { return None }; + let (w, h, px) = (*width, *height, std::mem::take(rgba)); + let sides = (1..=MAX_BITMAP_SIDE).contains(&w) && (1..=MAX_BITMAP_SIDE).contains(&h); + (sides && px.len() == (w as usize).saturating_mul(h as usize).saturating_mul(4)).then_some((w, h, px)) + } +} + +/// Segments as a kurbo path. A segment after a close opens a new subpath at the last start point. +fn bez(segs: &[Seg]) -> BezPath { + let mut p = BezPath::new(); + let mut start = (0.0, 0.0); + let mut open = false; + for s in segs { + match *s { + Seg::Move(x, y) => { + start = (f64::from(x), f64::from(y)); + p.move_to(start); + open = true; + } + Seg::Line(x, y) => { + if !open { + p.move_to(start); + } + p.line_to((f64::from(x), f64::from(y))); + open = true; + } + Seg::Cubic(a, b, c, d, e, f) => { + if !open { + p.move_to(start); + } + p.curve_to((f64::from(a), f64::from(b)), (f64::from(c), f64::from(d)), (f64::from(e), f64::from(f))); + open = true; + } + Seg::Close => { + if open { + p.close_path(); + } + open = false; + } + } + } + p +} + +/// Whether a placed box is finite and within ±[`MAX_COORD`]. +fn in_range(b: Rect) -> bool { + [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() < MAX_COORD) +} diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs new file mode 100644 index 00000000..4ee32578 --- /dev/null +++ b/crates/metafile/src/tests.rs @@ -0,0 +1,1018 @@ +//! Hand-built WMF and EMF buffers: each test writes the records it needs and checks the parsed model. + +use crate::canvas::Canvas; +use crate::dib::Rgba; +use crate::{Error, Item, MAX_BYTES, MAX_ITEMS, Seg, is_metafile, parse}; + +fn h16(v: i32) -> Vec { + (v as i16).to_le_bytes().to_vec() +} + +fn h32(v: u32) -> Vec { + v.to_le_bytes().to_vec() +} + +fn e32(v: i32) -> Vec { + v.to_le_bytes().to_vec() +} + +fn cat(parts: Vec>) -> Vec { + parts.concat() +} + +/// One WMF record: size in words, function, parameters (padded to a word boundary). +fn wrec(func: u16, params: &[u8]) -> Vec { + let mut p = params.to_vec(); + if !p.len().is_multiple_of(2) { + p.push(0); + } + let words = ((6 + p.len()) / 2) as u32; + let mut v = words.to_le_bytes().to_vec(); + v.extend(func.to_le_bytes()); + v.extend(p); + v +} + +/// A WMF file: optional placeable header (bbox left, top, right, bottom in logical units, 1440 per inch), +/// the standard header, the records and an end-of-file record. +fn wmf(records: &[Vec], placeable: Option<[i32; 4]>) -> Vec { + let body: Vec = records.concat(); + let eof = wrec(0, &[]); + let mut f = Vec::new(); + if let Some(bb) = placeable { + f.extend(0x9AC6_CDD7u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + for v in bb { + f.extend((v as i16).to_le_bytes()); + } + f.extend(1440u16.to_le_bytes()); + f.extend(0u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + } + let total = (f.len() + 18 + body.len() + eof.len()) / 2; + f.extend(1u16.to_le_bytes()); + f.extend(9u16.to_le_bytes()); + f.extend(0x300u16.to_le_bytes()); + f.extend((total as u32).to_le_bytes()); + f.extend(16u16.to_le_bytes()); + f.extend(0u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + f.extend(body); + f.extend(eof); + f +} + +/// One EMF record: type, size in bytes (payload padded to 4), payload. +fn erec(typ: u32, payload: &[u8]) -> Vec { + let mut p = payload.to_vec(); + while !p.len().is_multiple_of(4) { + p.push(0); + } + let mut v = typ.to_le_bytes().to_vec(); + v.extend(((8 + p.len()) as u32).to_le_bytes()); + v.extend(p); + v +} + +/// An EMF file with an 88-byte header (bounds and frame in 0.01 mm), the records and an EOF record. +fn emf(records: &[Vec], bounds: [i32; 4], frame: [i32; 4]) -> Vec { + let body: Vec = records.concat(); + let eof = erec(14, &[0; 12]); + let total = 88 + body.len() + eof.len(); + let mut h = vec![0u8; 88]; + h[0..4].copy_from_slice(&1u32.to_le_bytes()); + h[4..8].copy_from_slice(&88u32.to_le_bytes()); + h[8..24].copy_from_slice(&bounds.iter().flat_map(|v| v.to_le_bytes()).collect::>()); + h[24..40].copy_from_slice(&frame.iter().flat_map(|v| v.to_le_bytes()).collect::>()); + h[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes()); + h[44..48].copy_from_slice(&0x0001_0000u32.to_le_bytes()); + h[48..52].copy_from_slice(&(total as u32).to_le_bytes()); + h[52..56].copy_from_slice(&((records.len() + 1) as u32).to_le_bytes()); + h[56..58].copy_from_slice(&1u16.to_le_bytes()); + // 1000 x 500 device pixels for 250 x 125 mm: 0.04 device units per hundredth of a millimetre. + h[72..76].copy_from_slice(&1000i32.to_le_bytes()); + h[76..80].copy_from_slice(&500i32.to_le_bytes()); + h[80..84].copy_from_slice(&250i32.to_le_bytes()); + h[84..88].copy_from_slice(&125i32.to_le_bytes()); + let mut out = h; + out.extend(body); + out.extend(eof); + out +} + +/// A BITMAPINFOHEADER (40 bytes). +fn info_header(w: i32, h: i32, bpp: u16, size_image: u32) -> Vec { + cat(vec![h32(40), e32(w), e32(h), h16(1), h16(i32::from(bpp)), h32(0), h32(size_image), e32(0), e32(0), h32(0), h32(0)]) +} + +/// A 2x2 24-bit bottom-up DIB. File rows are stored bottom first: the bottom image row is blue, green; +/// the top row is red, white. Rows are padded to 4 bytes (8 bytes per row). +fn dib24_bottom_up() -> Vec { + let mut v = info_header(2, 2, 24, 16); + v.extend([255, 0, 0, 0, 255, 0, 0, 0]); + v.extend([0, 0, 255, 255, 255, 255, 0, 0]); + v +} + +/// Top-down RGBA of [`dib24_bottom_up`]: top-left red, top-right white, bottom-left blue, bottom-right green. +const DIB24_RGBA: [u8; 16] = [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]; + +fn path_segs(item: &Item) -> (&Vec, Option<[u8; 4]>, Option<([u8; 4], f32)>, bool) { + match item { + Item::Path { segs, fill, stroke, even_odd } => (segs, *fill, *stroke, *even_odd), + Item::Bitmap { .. } => panic!("expected a path"), + } +} + +fn close(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-3 +} + +#[test] +fn wmf_rectangle_with_solid_brush() { + let recs = [ + wrec(0x02FC, &cat(vec![h16(0), h32(0x0000_00FF), h16(0)])), + wrec(0x012D, &h16(0)), + wrec(0x041B, &cat(vec![h16(70), h16(110), h16(20), h16(10)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (100.0, 50.0)); + assert_eq!(p.items.len(), 1); + let (segs, fill, stroke, even_odd) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(100.0, 0.0), Seg::Line(100.0, 50.0), Seg::Line(0.0, 50.0), Seg::Close]); + assert_eq!(fill, Some([255, 0, 0, 255])); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); + assert!(even_odd); +} + +#[test] +fn wmf_polyline_with_pen() { + let recs = [ + wrec(0x02FA, &cat(vec![h16(0), h16(4), h16(0), h32(0x0000_FF00)])), + wrec(0x012D, &h16(0)), + wrec(0x0325, &cat(vec![h16(3), h16(0), h16(0), h16(50), h16(0), h16(50), h16(40)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (50.0, 40.0)); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(50.0, 0.0), Seg::Line(50.0, 40.0)]); + assert_eq!(fill, None); + assert_eq!(stroke, Some(([0, 255, 0, 255], 4.0))); +} + +#[test] +fn wmf_polygon_fill_rule_follows_polyfillmode() { + let square = cat(vec![h16(4), h16(0), h16(0), h16(10), h16(0), h16(10), h16(10), h16(0), h16(10)]); + let winding = [wrec(0x0106, &h16(2)), wrec(0x0324, &square)]; + let p = parse(&wmf(&winding, None)).unwrap(); + assert!(!path_segs(&p.items[0]).3, "WINDING fills use the nonzero rule"); + let alternate = [wrec(0x0324, &square)]; + let p = parse(&wmf(&alternate, None)).unwrap(); + let (segs, fill, _, even_odd) = path_segs(&p.items[0]); + assert!(even_odd, "default ALTERNATE is even-odd"); + assert_eq!(fill, Some([255, 255, 255, 255])); + assert_eq!(segs.last(), Some(&Seg::Close)); +} + +#[test] +fn wmf_stretchdib_flips_bottom_up_rows() { + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0), dib24_bottom_up()]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], None)).unwrap(); + assert_eq!((p.width, p.height), (20.0, 10.0)); + let Item::Bitmap { rect, width, height, rgba } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[0.0, 0.0, 20.0, 10.0]); + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn wmf_mapping_window_and_viewport() { + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020B, &cat(vec![h16(100), h16(100)])), + wrec(0x020C, &cat(vec![h16(50), h16(50)])), + wrec(0x020E, &cat(vec![h16(100), h16(100)])), + wrec(0x0214, &cat(vec![h16(100), h16(100)])), + wrec(0x0213, &cat(vec![h16(150), h16(150)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (100.0, 100.0)); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(100.0, 100.0)]); + assert_eq!(fill, None); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn wmf_placeable_header_sets_frame_in_points() { + let p = parse(&wmf(&[wrec(0x041B, &cat(vec![h16(1440), h16(2880), h16(0), h16(0)]))], Some([0, 0, 2880, 1440]))).unwrap(); + assert_eq!((p.width, p.height), (144.0, 72.0)); +} + +#[test] +fn emf_stretchdibits_flips_and_fits_frame() { + let mut payload = vec![0u8; 16]; + payload.extend(cat(vec![ + e32(0), + e32(0), + e32(0), + e32(0), + e32(2), + e32(2), + e32(80), + e32(40), + e32(120), + e32(16), + e32(0), + e32(0x00CC_0020), + e32(100), + e32(50), + ])); + payload.extend(info_header(2, 2, 24, 16)); + payload.extend(dib24_bottom_up().get(40..).unwrap_or_default()); + let rec = erec(81, &payload); + // A 25 x 12.5 mm frame is 70.87 x 35.43 points; the bitmap fills the whole frame. + let file = emf(&[rec], [0, 0, 100, 50], [0, 0, 2500, 1250]); + let p = parse(&file).unwrap(); + let (w, h) = (2500.0 * 72.0 / 2540.0, 1250.0 * 72.0 / 2540.0); + assert!(close(p.width, w as f32) && close(p.height, h as f32)); + let Item::Bitmap { rect, width, height, rgba } = &p.items[0] else { panic!("expected a bitmap") }; + assert!(rect.iter().zip([0.0, 0.0, w, h]).all(|(a, b)| close(*a, b as f32))); + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn emf_world_transform_scales_geometry() { + let xf = cat([2.0f32, 0.0, 0.0, 2.0, 0.0, 0.0].iter().map(|v| v.to_le_bytes().to_vec()).collect()); + let recs = [erec(35, &xf), erec(27, &cat(vec![e32(0), e32(0)])), erec(54, &cat(vec![e32(10), e32(5)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert_eq!((p.width, p.height), (20.0, 10.0)); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(20.0, 10.0)]); + assert_eq!(fill, None); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn emf_ellipse_is_four_cubics_back_to_start() { + let rect = cat(vec![e32(0), e32(0), e32(40), e32(20)]); + let p = parse(&emf(&[erec(42, &rect)], [0; 4], [0; 4])).unwrap(); + let (segs, fill, _, _) = path_segs(&p.items[0]); + assert_eq!(segs.len(), 6); + assert_eq!(segs[0], Seg::Move(40.0, 10.0)); + assert!(matches!(segs[4], Seg::Cubic(_, _, _, _, x, y) if close(x, 40.0) && close(y, 10.0))); + assert_eq!(segs[5], Seg::Close); + assert_eq!(fill, Some([255, 255, 255, 255])); +} + +#[test] +fn emf_path_bracket_strokeandfill_uses_stock_black_brush() { + let bounds = vec![0u8; 16]; + let recs = [ + erec(59, &[]), + erec(27, &cat(vec![e32(0), e32(0)])), + erec(54, &cat(vec![e32(10), e32(0)])), + erec(54, &cat(vec![e32(10), e32(10)])), + erec(61, &[]), + erec(60, &[]), + erec(37, &h32(0x8000_0004)), + erec(63, &bounds), + ]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 0.0), Seg::Line(10.0, 10.0), Seg::Close]); + assert_eq!(fill, Some([0, 0, 0, 255])); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn item_cap_stops_the_parse_and_keeps_the_rest() { + let rect = wrec(0x041B, &cat(vec![h16(10), h16(10), h16(0), h16(0)])); + let recs: Vec> = (0..MAX_ITEMS + 10).map(|_| rect.clone()).collect(); + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!(p.items.len(), MAX_ITEMS); +} + +#[test] +fn detection_and_garbage() { + let w = wmf(&[wrec(0x041B, &cat(vec![h16(1), h16(1), h16(0), h16(0)]))], None); + let e = emf(&[erec(42, &cat(vec![e32(0), e32(0), e32(4), e32(4)]))], [0; 4], [0; 4]); + assert!(is_metafile(&w)); + assert!(is_metafile(&e)); + assert!(!is_metafile(b"hello world, not a metafile")); + assert_eq!(parse(b"hello world, not a metafile").unwrap_err(), Error::NotMetafile); + assert!(parse(&[]).is_err()); +} + +/// Fixed-seed LCG (Knuth's constants): the fuzz inputs are reproducible. +struct Lcg(u64); + +impl Lcg { + fn rnd(&mut self) -> u32 { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 33) as u32 + } +} + +#[test] +fn truncated_random_and_mutated_inputs_never_panic() { + let rect = wrec(0x041B, &cat(vec![h16(10), h16(10), h16(0), h16(0)])); + let wfile = wmf( + &[ + wrec(0x02FC, &cat(vec![h16(0), h32(0xFF), h16(0)])), + wrec(0x012D, &h16(0)), + rect, + wrec(0x0325, &cat(vec![h16(2), h16(0), h16(0), h16(5), h16(5)])), + ], + Some([0, 0, 20, 10]), + ); + let mut dib_rec = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0)]); + dib_rec.extend(dib24_bottom_up()); + let mut payload = vec![0u8; 16]; + payload.extend(cat(vec![e32(0), e32(0), e32(0), e32(0), e32(2), e32(2), e32(80), e32(40), e32(120), e32(16), e32(0), e32(0), e32(20), e32(10)])); + payload.extend(info_header(2, 2, 24, 16)); + payload.extend(dib24_bottom_up().get(40..).unwrap_or_default()); + let efile = emf( + &[ + erec(59, &[]), + erec(27, &cat(vec![e32(0), e32(0)])), + erec(54, &cat(vec![e32(9), e32(9)])), + erec(61, &[]), + erec(60, &[]), + erec(63, &[0; 16]), + erec(81, &payload), + erec(86, &cat(vec![h32(4), h16(0), h16(0), h16(9), h16(9), h16(0), h16(9)])), + ], + [0, 0, 20, 10], + [0, 0, 2540, 1270], + ); + for file in [&wfile, &efile] { + for n in 0..=file.len() { + let prefix = &file[..n]; + let _ = parse(prefix); + let _ = is_metafile(prefix); + } + } + let mut rng = Lcg(0x2545_F491_4F6C_DD1D); + for _ in 0..3000 { + let len = (rng.rnd() % 400) as usize; + let buf: Vec = (0..len).map(|_| rng.rnd() as u8).collect(); + let _ = parse(&buf); + } + for i in 0..1500usize { + let mut buf = if i.is_multiple_of(2) { efile.clone() } else { wfile.clone() }; + for _ in 0..8 { + let at = rng.rnd() as usize % buf.len(); + buf[at] = rng.rnd() as u8; + } + let _ = parse(&buf); + } + for i in 0..500usize { + let mut buf = if i.is_multiple_of(2) { efile.clone() } else { wfile.clone() }; + let at = rng.rnd() as usize % buf.len(); + buf[at..].iter_mut().for_each(|b| *b = 0xFF); + let _ = parse(&buf); + } +} + +#[test] +fn wmf_stock_null_brush_by_16_bit_handle_has_no_fill() { + // SELECTOBJECT with 0x8005 is the NULL_BRUSH stock object (0x8000 | 5), so the rectangle is outline only. + let recs = [wrec(0x012D, &h16(0x8005u16 as i32)), wrec(0x041B, &cat(vec![h16(70), h16(110), h16(20), h16(10)]))]; + let p = parse(&wmf(&recs, None)).unwrap(); + let (_, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(fill, None); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn emf_frame_comes_from_rclframe_not_device_bounds() { + // Bounds cover only the left half of the 25 x 12.5 mm frame (device units are 0.04 per 0.01 mm), so a + // rectangle from x = 0 to 50 px ends at half the picture width. + let rec = erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)])); + let p = parse(&emf(&[rec], [0, 0, 50, 25], [0, 0, 2500, 1250])).unwrap(); + let w = 2500.0 * 72.0 / 2540.0; + assert!(close(p.width, w as f32) && close(p.height, (1250.0 * 72.0 / 2540.0) as f32)); + let (segs, ..) = path_segs(&p.items[0]); + assert!(matches!(segs[1], Seg::Line(x, _) if close(x, (w / 2.0) as f32)), "{segs:?}"); +} + +#[test] +fn emf_bounds_stand_in_when_the_frame_is_empty() { + let rec = erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)])); + let p = parse(&emf(&[rec], [0, 0, 50, 25], [0; 4])).unwrap(); + assert_eq!((p.width, p.height), (50.0, 25.0)); +} + +#[test] +fn wmf_placeable_bounds_go_through_the_window_mapping() { + // Logical bounds 200 x 100 at 1440 per inch are 10 x 5 points; the window is 2:1 onto the viewport, so + // the frame is 100 x 50 device units and the rectangle fills it. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020C, &cat(vec![h16(100), h16(200)])), + wrec(0x020E, &cat(vec![h16(50), h16(100)])), + wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, Some([0, 0, 200, 100]))).unwrap(); + assert_eq!((p.width, p.height), (10.0, 5.0)); + let (segs, ..) = path_segs(&p.items[0]); + assert!(segs.iter().any(|s| matches!(s, Seg::Line(x, y) if close(*x, 10.0) && close(*y, 5.0))), "{segs:?}"); +} + +#[test] +fn wmf_stretchdib_clipped_source_keeps_its_place() { + // Source x from -1 for 2 columns: only column 0 is in the bitmap, so it fills the right half of the + // destination and the picture keeps the original 20-unit width scale. + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(-1), h16(10), h16(20), h16(0), h16(0), dib24_bottom_up()]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], Some([0, 0, 20, 10]))).unwrap(); + let Item::Bitmap { rect, width, height, rgba } = &p.items[0] else { panic!("expected a bitmap") }; + // Placeable units are points: 20 units at 1440 per inch are 1 point, so the right column is 0.5 pt wide. + assert_eq!(rect, &[0.5, 0.0, 0.5, 0.5]); + assert_eq!((*width, *height), (1, 2)); + assert_eq!(rgba.as_slice(), [255, 0, 0, 255, 0, 0, 255, 255]); +} + +#[test] +fn wmf_negative_destination_width_mirrors_the_bitmap() { + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(-20), h16(0), h16(0), dib24_bottom_up()]); + // Frame from the placeable bounds (-20, 0)-(0, 10): the mirrored bitmap fills it. + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], Some([-20, 0, 0, 10]))).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[0.0, 0.0, 1.0, 0.5]); + assert_eq!(&rgba[..8], [255, 255, 255, 255, 255, 0, 0, 255]); +} + +#[test] +fn emf_blackness_bitblt_fills_black_without_a_source() { + let mut payload = vec![0u8; 16]; + payload.extend(cat(vec![e32(10), e32(20), e32(30), e32(40), e32(0x0000_0042)])); + let p = parse(&emf(&[erec(76, &payload)], [0; 4], [0; 4])).unwrap(); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(fill, Some([0, 0, 0, 255])); + assert_eq!(stroke, None); + assert_eq!(segs.len(), 5); +} + +#[test] +fn emf_savedc_past_the_stack_cap_stops_the_parse() { + let mut recs: Vec> = (0..=crate::MAX_STACK).map(|_| erec(33, &[])).collect(); + recs.push(erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))); + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert!(p.items.is_empty(), "records after the cap must not be drawn"); +} + +#[test] +fn canvas_stops_at_the_byte_budget() { + // Two 128 MB bitmaps fit the budget; the third would exceed it and stops the canvas. + let mut cv = Canvas::new(); + let big = || Rgba { w: 1, h: 1, bottom_up: false, px: vec![0u8; MAX_BYTES / 2] }; + cv.bitmap([0.0, 0.0, 1.0, 1.0], big()); + cv.bitmap([0.0, 0.0, 1.0, 1.0], big()); + assert!(!cv.full()); + cv.bitmap([0.0, 0.0, 1.0, 1.0], big()); + assert!(cv.full()); + assert_eq!(cv.items.len(), 2); +} + +#[test] +fn oversized_dib_header_size_is_rejected_without_overflow() { + let mut info = info_header(2, 2, 24, 16); + info[0..4].copy_from_slice(&0xFFFF_FFF0u32.to_le_bytes()); + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0), info]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], None)).unwrap(); + assert!(p.items.is_empty()); +} + +/// An EMF record of `len` bytes (header included) with u32 fields and byte blobs written at absolute +/// record offsets, so each test places a field where the spec puts it. +fn erec_at(typ: u32, len: usize, fields: &[(usize, u32)], blobs: &[(usize, &[u8])]) -> Vec { + let mut v = vec![0u8; len]; + v[0..4].copy_from_slice(&typ.to_le_bytes()); + v[4..8].copy_from_slice(&(len as u32).to_le_bytes()); + for (off, val) in fields { + v[*off..*off + 4].copy_from_slice(&val.to_le_bytes()); + } + for (off, b) in blobs { + v[*off..*off + b.len()].copy_from_slice(b); + } + v +} + +/// A BITBLT-style record (BITBLT, STRETCHBLT, ALPHABLEND layout) with its DIB at 112: the bmi and bits +/// offsets and lengths go at 84 and 92. `fields` and `blobs` add the other fields. +fn dib_rec(typ: u32, fields: &[(usize, u32)], blobs: &[(usize, &[u8])], info: &[u8], bits: &[u8]) -> Vec { + let at = 112; + let bits_at = at + info.len(); + let mut f = fields.to_vec(); + f.extend([(84, at as u32), (88, info.len() as u32), (92, bits_at as u32), (96, bits.len() as u32)]); + let mut b: Vec<(usize, &[u8])> = blobs.to_vec(); + b.extend([(at, info), (bits_at, bits)]); + erec_at(typ, bits_at + bits.len(), &f, &b) +} + +/// BITBLT (76) with destination `dst` = [x, y, w, h] and a 40-byte header DIB. +fn bitblt_rec(rop: u32, dst: [u32; 4], info: &[u8], bits: &[u8]) -> Vec { + let fields = [(24, dst[0]), (28, dst[1]), (32, dst[2]), (36, dst[3]), (40, rop)]; + dib_rec(76, &fields, &[], info, bits) +} + +#[test] +fn wmf_font_takes_an_object_slot_so_handles_line_up() { + // The font is handle 0, so the pen created after it is handle 1. + let recs = [ + wrec(0x02FB, &[0u8; 18]), + wrec(0x02FA, &cat(vec![h16(0), h16(4), h16(0), h32(0x0000_FF00)])), + wrec(0x012D, &h16(1)), + wrec(0x0325, &cat(vec![h16(2), h16(0), h16(0), h16(50), h16(0), h16(50)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + let (_, _, stroke, _) = path_segs(&p.items[0]); + assert_eq!(stroke, Some(([0, 255, 0, 255], 4.0))); +} + +#[test] +fn emf_polybezierto16_continues_the_open_path() { + // BEGINPATH, MOVETO a, LINETO b, POLYBEZIERTO16, CLOSEFIGURE, FILLPATH: one closed figure from a. + let mut bezier = vec![0u8; 16]; + bezier.extend(cat(vec![e32(3), h16(10), h16(10), h16(20), h16(10), h16(20), h16(0)])); + let recs = [ + erec(59, &[]), + erec(27, &cat(vec![e32(0), e32(0)])), + erec(54, &cat(vec![e32(10), e32(0)])), + erec(88, &bezier), + erec(61, &[]), + erec(62, &[0; 16]), + ]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + let (segs, fill, _, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 0.0), Seg::Cubic(10.0, 10.0, 20.0, 10.0, 20.0, 0.0), Seg::Close]); + assert_eq!(fill, Some([255, 255, 255, 255])); +} + +#[test] +fn emf_polybezier_starts_its_own_figure_and_keeps_the_current_point() { + // POLYBEZIER (2) starts at its first point; the LINETO after it still starts at the MOVETO point. + let mut bezier = vec![0u8; 16]; + bezier.extend(cat(vec![e32(4), e32(0), e32(0), e32(5), e32(5), e32(10), e32(5), e32(10), e32(0)])); + let recs = [erec(27, &cat(vec![e32(3), e32(3)])), erec(2, &bezier), erec(54, &cat(vec![e32(50), e32(50)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 2); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs[0], Seg::Move(0.0, 0.0)); + let (segs, ..) = path_segs(&p.items[1]); + assert_eq!(segs, &vec![Seg::Move(3.0, 3.0), Seg::Line(50.0, 50.0)]); +} + +#[test] +fn hairline_pen_stays_zero_under_an_anisotropic_window() { + // Window 2000 x 2000 onto a 1 x 1 viewport: a cosmetic pen must not become one device unit wide. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020C, &cat(vec![h16(2000), h16(2000)])), + wrec(0x020E, &cat(vec![h16(1), h16(1)])), + wrec(0x0214, &cat(vec![h16(0), h16(0)])), + wrec(0x0213, &cat(vec![h16(2000), h16(2000)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (1.0, 1.0)); + let (segs, _, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs.last(), Some(&Seg::Line(1.0, 1.0))); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn null_pen_is_recognised_under_extra_style_bits() { + // Pen style 0x105: PS_NULL (5) with a flag above the style nibble still draws no outline. + let pen = erec_at(38, 28, &[(8, 0), (12, 0x105), (16, 1), (24, 0)], &[]); + let recs = [pen, erec(37, &h32(0)), erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + let (_, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(fill, Some([255, 255, 255, 255])); + assert_eq!(stroke, None); +} + +#[test] +fn metric_map_mode_flips_y_and_scales_to_device_pixels_emf() { + // LOMETRIC is 0.1 mm per unit; the header maps 4 device pixels to 1 mm. Points go y-up, so the + // drawing lies below its origin and the picture frame moves up to its top. + let recs = [erec(17, &h32(2)), erec(27, &cat(vec![e32(0), e32(0)])), erec(54, &cat(vec![e32(100), e32(50)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + let (segs, ..) = path_segs(&p.items[0]); + assert!(close_seg(&segs[0], Seg::Move(0.0, 20.0)) && close_seg(&segs[1], Seg::Line(40.0, 0.0)), "{segs:?}"); +} + +#[test] +fn metric_map_mode_uses_96_dpi_for_wmf() { + let recs = [wrec(0x0103, &h16(2)), wrec(0x0214, &cat(vec![h16(0), h16(0)])), wrec(0x0213, &cat(vec![h16(50), h16(100)]))]; + let p = parse(&wmf(&recs, None)).unwrap(); + let px_mm = 96.0 / 25.4; + let (segs, ..) = path_segs(&p.items[0]); + let (x, y) = (100.0 * 0.1 * px_mm, 50.0 * 0.1 * px_mm); + assert!(close_seg(&segs[0], Seg::Move(0.0, y as f32)) && close_seg(&segs[1], Seg::Line(x as f32, 0.0)), "{segs:?}"); +} + +/// Segment equality within [`close`] tolerance. +fn close_seg(a: &Seg, b: Seg) -> bool { + match (*a, b) { + (Seg::Move(x, y), Seg::Move(u, v)) | (Seg::Line(x, y), Seg::Line(u, v)) => close(x, u) && close(y, v), + _ => false, + } +} + +#[test] +fn isotropic_mode_uses_one_scale_and_keeps_axis_signs() { + // Window 100 x 50; viewport 400 x -100: the scale is min(4, 2) = 2 on both axes, y flipped. + let recs = [ + wrec(0x0103, &h16(7)), + wrec(0x020C, &cat(vec![h16(50), h16(100)])), + wrec(0x020E, &cat(vec![h16(-100), h16(400)])), + wrec(0x0214, &cat(vec![h16(0), h16(0)])), + wrec(0x0213, &cat(vec![h16(25), h16(50)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + let (segs, ..) = path_segs(&p.items[0]); + assert!(close_seg(&segs[0], Seg::Move(0.0, 100.0)) && close_seg(&segs[1], Seg::Line(100.0, 50.0)), "{segs:?}"); +} + +#[test] +fn modify_world_with_an_unknown_mode_keeps_the_transform() { + let scale2 = cat([2.0f32, 0.0, 0.0, 2.0, 0.0, 0.0].iter().map(|v| v.to_le_bytes().to_vec()).collect()); + let identity = cat([1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0].iter().map(|v| v.to_le_bytes().to_vec()).collect()); + let recs = + [erec(35, &scale2), erec(36, &cat(vec![identity, h32(99)])), erec(27, &cat(vec![e32(0), e32(0)])), erec(54, &cat(vec![e32(10), e32(5)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(20.0, 10.0)]); +} + +#[test] +fn round_rect_accepts_reversed_corners() { + let normal = crate::geom::round_rect(0.0, 0.0, 10.0, 20.0, 4.0, 4.0); + assert_eq!(crate::geom::round_rect(10.0, 20.0, 0.0, 0.0, 4.0, 4.0), normal); + assert_eq!(crate::geom::round_rect(10.0, 0.0, 0.0, 20.0, 4.0, 4.0), normal); +} + +#[test] +fn stroke_width_is_clamped_to_the_picture_diagonal() { + // A 1440-unit placeable picture is 72 points square (diagonal 101.8); a 5000-unit pen would be 250. + let recs = [ + wrec(0x02FA, &cat(vec![h16(0), h16(5000), h16(0), h32(0)])), + wrec(0x012D, &h16(0)), + wrec(0x041B, &cat(vec![h16(1440), h16(1440), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, Some([0, 0, 1440, 1440]))).unwrap(); + assert_eq!((p.width, p.height), (72.0, 72.0)); + let (_, _, stroke, _) = path_segs(&p.items[0]); + assert!(close(stroke.unwrap_or(([0; 4], 0.0)).1, 72.0 * 2.0f32.sqrt()), "{stroke:?}"); +} + +/// A WMF rectangle from (0, 0) to (1, 1), so a picture's frame starts at the origin. +fn origin_rect() -> Vec { + wrec(0x041B, &cat(vec![h16(1), h16(1), h16(0), h16(0)])) +} + +#[test] +fn wmf_dibbitblt_without_a_dib_is_a_pattern_fill() { + // The 12-word form: rop(4), ySrc, xSrc, reserved, height, width, yDest, xDest; PATCOPY fills with the brush. + let params = cat(vec![h32(0x00F0_0021), h16(0), h16(0), h16(0), h16(10), h16(20), h16(5), h16(7)]); + let p = parse(&wmf(&[origin_rect(), wrec(0x0940, ¶ms)], None)).unwrap(); + let (segs, fill, _, _) = path_segs(&p.items[1]); + assert_eq!(fill, Some([255, 255, 255, 255])); + assert_eq!(segs, &vec![Seg::Move(7.0, 5.0), Seg::Line(27.0, 5.0), Seg::Line(27.0, 15.0), Seg::Line(7.0, 15.0), Seg::Close]); +} + +#[test] +fn wmf_dibstretchblt_without_a_dib_is_a_rop_fill() { + // The 14-word form: rop(4), srcH, srcW, ySrc, xSrc, reserved, destH, destW, yDest, xDest; BLACKNESS fills black. + let params = cat(vec![h32(0x0000_0042), h16(2), h16(2), h16(0), h16(0), h16(0), h16(10), h16(20), h16(5), h16(7)]); + let p = parse(&wmf(&[origin_rect(), wrec(0x0B41, ¶ms)], None)).unwrap(); + let (segs, fill, stroke, _) = path_segs(&p.items[1]); + assert_eq!((fill, stroke), (Some([0, 0, 0, 255]), None)); + assert_eq!(segs, &vec![Seg::Move(7.0, 5.0), Seg::Line(27.0, 5.0), Seg::Line(27.0, 15.0), Seg::Line(7.0, 15.0), Seg::Close]); +} + +#[test] +fn wmf_dibstretchblt_stretches_the_dib_into_its_destination() { + // rop(4), srcH, srcW, ySrc, xSrc, destH, destW, yDest, xDest, then the DIB (no reserved word). + let mut params = cat(vec![h32(0x00CC_0020), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(6), h16(5)]); + params.extend(dib24_bottom_up()); + let p = parse(&wmf(&[origin_rect(), wrec(0x0B41, ¶ms)], None)).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[1] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[5.0, 6.0, 20.0, 10.0]); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn wmf_negative_destination_height_mirrors_the_rows() { + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(-10), h16(20), h16(0), h16(0), dib24_bottom_up()]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], None)).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[0.0, 0.0, 20.0, 10.0]); + // Blue, green on top; red, white below. + assert_eq!(rgba.as_slice(), [0, 0, 255, 255, 0, 255, 0, 255, 255, 0, 0, 255, 255, 255, 255, 255]); +} + +#[test] +fn wmf_dibbitblt_copies_the_dib_at_its_destination() { + // The rectangle fixes the picture origin at (0, 0); the DIB lands at x = 5, y = 6. + let mut params = cat(vec![h32(0x00CC_0020), h16(0), h16(0), h16(2), h16(2), h16(6), h16(5)]); + params.extend(dib24_bottom_up()); + let recs = [wrec(0x041B, &cat(vec![h16(10), h16(10), h16(0), h16(0)])), wrec(0x0940, ¶ms)]; + let p = parse(&wmf(&recs, None)).unwrap(); + let Item::Bitmap { rect, width, height, rgba } = &p.items[1] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[5.0, 6.0, 2.0, 2.0]); + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn emf_setdibitstodevice_draws_the_image_at_its_own_size() { + let dib = dib24_bottom_up(); + let (info, bits) = (&dib[..40], &dib[40..]); + // Destination x/y at 24, src x/y at 32, size at 40, bmi at 48 (offset, length), bits at 56, scan start + // 68 and scan count 72. + let fields = [(24, 10), (28, 20), (40, 2), (44, 2), (48, 112), (52, 40), (56, 152), (60, 16), (72, 2)]; + let rec = erec_at(80, 168, &fields, &[(112, info), (152, bits)]); + let p = parse(&emf(&[rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[0.0, 0.0, 2.0, 2.0]); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn emf_setdibitstodevice_source_counts_from_the_start_scan() { + // ySrc 1 with the bits starting at scan 1 is source row 0 of the bits: the bottom row (blue, green), + // drawn cxSrc x cySrc = 2 x 1 at the destination. + let dib = dib24_bottom_up(); + let (info, bits) = (&dib[..40], &dib[40..]); + let fields = [(24, 10), (28, 20), (36, 1), (40, 2), (44, 1), (48, 112), (52, 40), (56, 152), (60, 16), (68, 1), (72, 2)]; + let rec = erec_at(80, 168, &fields, &[(112, info), (152, bits)]); + let r = erec(43, &cat(vec![e32(0), e32(0), e32(1), e32(1)])); + let p = parse(&emf(&[r, rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[1] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[10.0, 20.0, 2.0, 1.0]); + assert_eq!(rgba.as_slice(), [0, 0, 255, 255, 0, 255, 0, 255]); +} + +#[test] +fn emf_alphablend_uses_constant_and_per_pixel_alpha() { + // Constant alpha 128 (BLENDFUNCTION bytes at 40: op, flags, constant, format) over a 24-bit DIB. + let blend = [0u8, 0, 128, 0]; + let dib = dib24_bottom_up(); + let rec = dib_rec(114, &[(24, 0), (28, 0), (32, 2), (36, 2), (44, 0), (48, 0), (100, 2), (104, 2)], &[(40, &blend[..])], &dib[..40], &dib[40..]); + let p = parse(&emf(&[rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + let alpha: Vec = rgba.as_chunks::<4>().0.iter().map(|px| px[3]).collect(); + assert_eq!(alpha, vec![128; 4]); + // Per-pixel alpha (AC_SRC_ALPHA) over a 32-bit premultiplied DIB: BGRA 0, 0, 128, 128 is straight red at + // half alpha, and the constant 128 halves the alpha again. + let one_px = |constant: u8, bgra: [u8; 4]| { + let blend = [0u8, 0, constant, 1]; + let info = info_header(1, 1, 32, 4); + let rec = dib_rec(114, &[(24, 0), (28, 0), (32, 1), (36, 1), (44, 0), (48, 0), (100, 1), (104, 1)], &[(40, &blend[..])], &info, &bgra); + let p = parse(&emf(&[rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + rgba.clone() + }; + assert_eq!(one_px(255, [0, 0, 128, 128]), [255, 0, 0, 128]); + assert_eq!(one_px(128, [0, 0, 128, 128]), [255, 0, 0, 64]); + assert_eq!(one_px(255, [10, 20, 64, 128]), [128, 40, 20, 128], "rounded to the nearest value"); +} + +#[test] +fn emf_srcand_mask_draws_all_but_its_white_pixels() { + // SRCAND over white paper is the source itself; white keeps the destination, so it is transparent. + let dib = dib24_bottom_up(); + let rec = bitblt_rec(0x0088_00C6, [0, 0, 2, 2], &dib[..40], &dib[40..]); + let p = parse(&emf(&[rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rgba.as_slice(), [255, 0, 0, 255, 255, 255, 255, 0, 0, 0, 255, 255, 0, 255, 0, 255]); +} + +#[test] +fn emf_srcpaint_makes_black_pixels_transparent() { + // A 2 x 1 DIB: black, then red. SRCPAINT leaves black transparent; SRCCOPY keeps it opaque. + let info = info_header(2, 1, 24, 8); + let bits = [0u8, 0, 0, 0, 0, 255, 0, 0]; + let p = parse(&emf(&[bitblt_rec(0x00EE_0086, [0, 0, 2, 1], &info, &bits)], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rgba.as_slice(), [0, 0, 0, 0, 255, 0, 0, 255]); + let p = parse(&emf(&[bitblt_rec(0x00CC_0020, [0, 0, 2, 1], &info, &bits)], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rgba.as_slice(), [0, 0, 0, 255, 255, 0, 0, 255]); +} + +#[test] +fn wmf_dib_palette_entries_are_skipped_before_the_bits() { + // A 24-bit DIB with biClrUsed = 2 carries two RGBQUADs (8 bytes) before its pixel bits. + let mut info = info_header(2, 2, 24, 16); + info[32..36].copy_from_slice(&2u32.to_le_bytes()); + let mut dib = info; + dib.extend([9u8; 8]); + dib.extend(&dib24_bottom_up()[40..]); + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0), dib]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], None)).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn emf_point_counts_past_the_budget_are_rejected() { + // A count of 4 billion points in a record that cannot hold them: nothing is drawn and nothing is allocated. + let mut poly = vec![0u8; 16]; + poly.extend(cat(vec![h32(u32::MAX), h32(0), h32(0)])); + let mut groups = vec![0u8; 16]; + groups.extend(cat(vec![h32(u32::MAX), h32(u32::MAX), h32(1)])); + let p = parse(&emf(&[erec(4, &poly), erec(7, &groups)], [0; 4], [0; 4])).unwrap(); + assert!(p.items.is_empty()); +} + +#[test] +fn emf_polypolygon_counts_must_fit_the_record() { + // A polygon count within the point budget in a record too short to hold the counts draws nothing. + let mut short = vec![0u8; 16]; + short.extend(cat(vec![h32(crate::MAX_PATH_OPS as u32), h32(3), h32(3)])); + // POLYPOLYGON16: two triangles of three points each. + let mut two = vec![0u8; 16]; + two.extend(cat(vec![h32(2), h32(6), h32(3), h32(3)])); + two.extend(cat([0, 0, 10, 0, 0, 10, 20, 0, 30, 0, 20, 10].iter().map(|v| h16(*v)).collect())); + let p = parse(&emf(&[erec(91, &short), erec(91, &two)], [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Move(..))).count(), 2, "{segs:?}"); +} + +/// EMR_EXTCREATEPEN into slot 1 with LogPenEx style, width and brush style, then a selected rectangle. +fn ext_pen_rect(style: u32, width: u32, brush: u32) -> Option<([u8; 4], f32)> { + let pen = erec_at(95, 52, &[(8, 1), (28, style), (32, width), (36, brush), (40, 0x0000_FF00)], &[]); + let recs = [pen, erec(37, &h32(1)), erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + path_segs(&p.items[0]).2 +} + +#[test] +fn emf_ext_pen_cosmetic_is_a_hairline_and_null_brush_draws_nothing() { + // PS_COSMETIC widths are device pixels: a hairline, not one logical unit. + assert_eq!(ext_pen_rect(0, 1, 0), Some(([0, 255, 0, 255], 0.0))); + // PS_GEOMETRIC (0x10000) keeps its logical width. + assert_eq!(ext_pen_rect(0x0001_0000, 4, 0), Some(([0, 255, 0, 255], 4.0))); + // A BS_NULL brush draws no line. + assert_eq!(ext_pen_rect(0x0001_0000, 4, 1), None); +} + +#[test] +fn flip_tolerates_a_short_pixel_buffer() { + let mut img = Rgba { w: 2, h: 2, bottom_up: false, px: vec![1u8; 7] }; + crate::dib::flip(&mut img, true, true); + assert_eq!(img.px.len(), 7); + // Three rows held of four: only the rows present swap (0 with 2). + let mut img = Rgba { w: 1, h: 4, bottom_up: false, px: (0u8..12).collect() }; + crate::dib::flip(&mut img, false, true); + assert_eq!(img.px, [8, 9, 10, 11, 4, 5, 6, 7, 0, 1, 2, 3]); +} + +#[test] +fn place_fits_the_frame_scales_strokes_and_drops_far_paths() { + let pic = crate::Picture { + width: 100.0, + height: 50.0, + items: vec![ + Item::Path { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 10.0)], fill: None, stroke: Some(([0, 0, 0, 255], 4.0)), even_odd: true }, + Item::Path { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(2e6, 0.0)], fill: None, stroke: None, even_odd: true }, + ], + }; + let placed = pic.place(kurbo::Rect::new(0.0, 0.0, 200.0, 100.0), [0.0; 4]); + assert_eq!(placed.len(), 1); + let crate::PlacedItem::Path { stroke, .. } = &placed[0] else { panic!("expected a path") }; + assert_eq!(stroke.map(|s| s.1), Some(8.0)); +} + +#[test] +fn place_keeps_only_finite_bounded_bitmaps_with_area() { + let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4] }; + let pic = crate::Picture { + width: 100.0, + height: 50.0, + items: vec![ + bitmap([f32::NAN, 0.0, 10.0, 10.0]), + bitmap([0.0, 0.0, 0.0, 10.0]), + bitmap([0.0, 0.0, 2e6, 10.0]), + bitmap([0.0, 0.0, f32::INFINITY, 10.0]), + bitmap([10.0, 5.0, 10.0, 10.0]), + ], + }; + let placed = pic.place(kurbo::Rect::new(0.0, 0.0, 200.0, 100.0), [0.0; 4]); + assert_eq!(placed.len(), 1); + let crate::PlacedItem::Bitmap { index, rect } = &placed[0] else { panic!("expected a bitmap") }; + assert_eq!((*index, *rect), (4, kurbo::Rect::new(20.0, 10.0, 40.0, 30.0))); +} + +#[test] +fn take_pixels_returns_only_drawable_bitmaps() { + let mut ok = Item::Bitmap { rect: [0.0; 4], width: 2, height: 1, rgba: vec![7; 8] }; + assert_eq!(ok.take_pixels(), Some((2, 1, vec![7; 8]))); + assert!(matches!(&ok, Item::Bitmap { rgba, .. } if rgba.is_empty()), "the pixels move out"); + let mut short = Item::Bitmap { rect: [0.0; 4], width: 2, height: 2, rgba: vec![0; 8] }; + assert_eq!(short.take_pixels(), None); + let side = crate::MAX_BITMAP_SIDE + 1; + let mut wide = Item::Bitmap { rect: [0.0; 4], width: side, height: 1, rgba: vec![0; side as usize * 4] }; + assert_eq!(wide.take_pixels(), None); + let mut path = Item::Path { segs: Vec::new(), fill: None, stroke: None, even_odd: false }; + assert_eq!(path.take_pixels(), None); + let pic = |width: f32, height: f32| crate::Picture { width, height, items: Vec::new() }; + assert!(pic(1.0, 2.0).has_size()); + assert!(!pic(0.0, 2.0).has_size() && !pic(f32::NAN, 2.0).has_size() && !pic(1.0, f32::INFINITY).has_size()); +} + +/// The rectangle a WMF RECTANGLE record covers, from its path's corners. +fn rect_extent(item: &Item) -> (f32, f32, f32, f32) { + let (segs, ..) = path_segs(item); + let pts: Vec<(f32, f32)> = segs + .iter() + .filter_map(|s| match s { + Seg::Move(x, y) | Seg::Line(x, y) => Some((*x, *y)), + _ => None, + }) + .collect(); + let (xs, ys): (Vec, Vec) = pts.into_iter().unzip(); + let lo = |v: &[f32]| v.iter().copied().fold(f32::MAX, f32::min); + let hi = |v: &[f32]| v.iter().copied().fold(f32::MIN, f32::max); + (lo(&xs), lo(&ys), hi(&xs), hi(&ys)) +} + +#[test] +fn plain_anisotropic_wmf_without_a_viewport_fills_its_window() { + // Anisotropic, window 200 x 100, no viewport (a logo strip saved without a placeable header): the + // window is the target, not a 1 x 1 viewport that squeezes the picture to a point. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020B, &cat(vec![h16(0), h16(0)])), + wrec(0x020C, &cat(vec![h16(100), h16(200)])), + wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (200.0, 100.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn placeable_wmf_window_records_replace_the_bounds() { + // The placeable bounds say 1440 square (72 points); the file then draws in a window centred on the + // origin, as a coat of arms saved by a drawing program does. The window maps onto the 72-point + // target, so the drawing fills it instead of being blown up around the bounds. + let recs = [ + wrec(0x020C, &cat(vec![h16(2000), h16(2000)])), + wrec(0x020B, &cat(vec![h16(-1000), h16(-1000)])), + wrec(0x041B, &cat(vec![h16(1000), h16(1000), h16(-1000), h16(-1000)])), + ]; + let p = parse(&wmf(&recs, Some([238, 539, 1678, 1979]))).unwrap(); + assert_eq!((p.width, p.height), (72.0, 72.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 72.0) && close(y1, 72.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn placeable_wmf_keeps_its_target_when_the_window_changes_shape() { + // Bounds 600 x 300 (30 x 15 points), then a portrait window 100 x 200 for a bitmap-sized drawing: + // the drawing still fills the target (Word stretches it to the picture's box). + let recs = [wrec(0x0103, &h16(8)), wrec(0x020C, &cat(vec![h16(200), h16(100)])), wrec(0x041B, &cat(vec![h16(200), h16(100), h16(0), h16(0)]))]; + let p = parse(&wmf(&recs, Some([0, 0, 600, 300]))).unwrap(); + assert_eq!((p.width, p.height), (30.0, 15.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 30.0) && close(y1, 15.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn rop2_nop_draws_nothing_until_the_mode_changes() { + // A coat of arms that first draws its bounding rectangle with the R2_NOP mix mode (no output), + // then its shapes with R2_COPYPEN. Restoring a saved state brings R2_NOP back. + let rect = |n: i32| wrec(0x041B, &cat(vec![h16(n), h16(n), h16(0), h16(0)])); + let recs = [wrec(0x0104, &h16(11)), rect(100), wrec(0x001E, &[]), wrec(0x0104, &h16(13)), rect(50), wrec(0x0127, &h16(-1)), rect(20)]; + let p = parse(&wmf(&recs, Some([0, 0, 100, 100]))).unwrap(); + assert_eq!(p.items.len(), 1, "{:?}", p.items); + let (_, _, x1, y1) = rect_extent(&p.items[0]); + // 50 of 100 units in a 5-point picture. + assert!(close(x1, 2.5) && close(y1, 2.5), "{x1} {y1}"); +} + +#[test] +fn plain_wmf_target_comes_from_a_window_that_was_read() { + // A window record too short to read, then the real one: the target is the real window, not the + // 1 x 1 default the short record left in place. A viewport origin set before it is kept. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020D, &cat(vec![h16(0), h16(0)])), + wrec(0x020C, &[]), + wrec(0x020C, &cat(vec![h16(100), h16(200)])), + wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (200.0, 100.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}"); +} diff --git a/crates/metafile/src/wmf.rs b/crates/metafile/src/wmf.rs new file mode 100644 index 00000000..d221a765 --- /dev/null +++ b/crates/metafile/src/wmf.rs @@ -0,0 +1,273 @@ +//! WMF reader (MS-WMF). A file is an optional 22-byte placeable header, a 9-word standard header, then +//! records of (size in 16-bit words, function, parameters). Parameters are read at byte 6 of each record. + +use crate::bytes::Bytes; +use crate::canvas::{Brush, Canvas, Obj, Pen, colorref}; +use crate::dib; +use crate::geom::{self, ArcKind, P}; +use crate::{Error, Frame, MAX_PATH_OPS}; + +const PLACEABLE_KEY: u32 = 0x9AC6_CDD7; +const DEFAULT_INCH: f64 = 1440.0; + +pub(crate) fn is_wmf(b: Bytes) -> bool { + if b.u32(0) == Some(PLACEABLE_KEY) { + return true; + } + matches!(b.u16(0), Some(1 | 2)) && b.u16(2) == Some(9) && matches!(b.u16(4), Some(0x100 | 0x300)) +} + +/// Reads all records. Returns the drawing state and the picture frame, or an error when no record +/// could be read at all. +pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { + let placeable = input.u32(0) == Some(PLACEABLE_KEY); + // Placeable bounds: logical corners (left, top, right, bottom) and their size in points. + let (hdr, bounds) = if placeable { + let [l, t, r, b] = [6, 8, 10, 12].map(|o| input.i16(o).map(f64::from)); + let (Some(l), Some(t), Some(r), Some(b)) = (l, t, r, b) else { return Err(Error::BadHeader) }; + let inch = input.u16(14).filter(|v| *v > 0).map_or(DEFAULT_INCH, f64::from); + let (w, h) = (r - l, b - t); + let bounds = (w > 0.0 && h > 0.0).then_some(([l, t, r, b], (w * 72.0 / inch, h * 72.0 / inch))); + (22, bounds) + } else { + (0, None) + }; + let hsize = input.u16(hdr + 2).ok_or(Error::BadHeader)? as usize; + if hsize < 9 { + return Err(Error::BadHeader); + } + let mut pos = hdr.checked_add(hsize.checked_mul(2).ok_or(Error::BadHeader)?).ok_or(Error::BadHeader)?; + // Played into a target like Word does: the placeable bounds are the first window, the target (the + // picture size) the viewport. A plain WMF's target is its first window. + let mut cv = Canvas::new(); + if let Some((c, size)) = bounds { + cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size); + } + let mut player_viewport = bounds.is_some(); + let mut parsed = 0usize; + while !cv.full() { + let (Some(words), Some(func)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u16(o))) else { break }; + let Some(len) = (words as usize).checked_mul(2).filter(|l| *l >= 6) else { break }; + let Some(rec) = input.slice(pos, len) else { break }; + if func == 0 { + break; + } + record(&mut cv, func, Bytes(rec)); + if func == 0x020C && cv.window_set && !player_viewport && !cv.viewport_set { + let we = cv.map.window_ext(); + cv.play_into(None, (we.0.abs(), we.1.abs())); + player_viewport = true; + } + parsed += 1; + let Some(next) = pos.checked_add(len) else { break }; + pos = next; + } + if parsed == 0 { + return Err(Error::NoRecords); + } + let frame = match bounds { + // Played into its target: the frame is the target. + Some((_, size)) if !cv.viewport_set && cv.map.uses_extents() => Some(Frame { rect: [0.0, 0.0, size.0, size.1], size: Some(size) }), + // The file set its own viewport, so the drawing is in its device units and the logical bounds + // go through the final mapping. + Some((c, size)) => { + let (a, b) = (cv.map.device((c[0], c[1])), cv.map.device((c[2], c[3]))); + let rect = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]; + (rect[2] > 0.0 && rect[3] > 0.0).then_some(Frame { rect, size: Some(size) }) + } + None if player_viewport && !cv.viewport_set && cv.map.uses_extents() => { + let ve = cv.map.viewport_ext(); + Some(Frame { rect: [0.0, 0.0, ve.0, ve.1], size: None }) + } + None if cv.window_set => Some(Frame { rect: cv.map.window_rect(), size: None }), + None => None, + }; + Ok((cv, frame)) +} + +fn record(cv: &mut Canvas, func: u16, r: Bytes) { + if cv.full() { + return; + } + let _ = match func { + 0x0103 => r.u16(6).map(|m| cv.set_map_mode(u32::from(m))), + 0x0106 => r.u16(6).map(|m| cv.set_polyfill(u32::from(m))), + 0x0104 => r.u16(6).map(|m| cv.set_rop2(u32::from(m))), + // Y is stored before X in these records. + 0x020B => xy16(r, 6).map(|p| cv.set_window_org(p)), + 0x020C => xy16(r, 6).map(|p| cv.set_window_ext(p)), + 0x020D => xy16(r, 6).map(|p| cv.set_viewport_org(p)), + 0x020E => xy16(r, 6).map(|p| cv.set_viewport_ext(p)), + 0x001E => { + cv.save_dc(); + Some(()) + } + 0x0127 => r.i16(6).map(|n| cv.restore_dc(i32::from(n))), + 0x0214 => xy16(r, 6).map(|p| cv.move_to(p)), + 0x0213 => xy16(r, 6).map(|p| cv.line_to(p)), + 0x0325 => counted_points(r).map(|pts| cv.poly(&pts, false, false, true)), + 0x0324 => counted_points(r).map(|pts| cv.poly(&pts, true, true, true)), + 0x0538 => polypolygon(r).map(|ops| cv.shape(&ops, true, true)), + 0x041B => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)), + 0x061C => { + let [ew, eh] = [r.i16(8), r.i16(6)]; + let (Some(ew), Some(eh)) = (ew, eh) else { return }; + rect16(r, 10).map(|[l, t, rr, b]| { + cv.shape(&geom::round_rect(l, t, rr, b, f64::from(ew), f64::from(eh)), true, true); + }) + } + 0x0418 => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)), + 0x0817 | 0x081A | 0x0830 => arc16(r).map(|(bounds, s, e)| { + let kind = match func { + 0x0817 => ArcKind::Arc, + 0x081A => ArcKind::Pie, + _ => ArcKind::Chord, + }; + let [l, t, rr, b] = bounds; + cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true); + }), + 0x02FA => create_pen(cv, r), + 0x02FC => create_brush(cv, r), + // Fonts, palettes, pattern brushes and regions take a handle slot too, so later SELECTOBJECT + // handles count them; selecting one draws nothing. + 0x02FB | 0x00F7 | 0x01F9 | 0x0142 | 0x06FF => { + cv.add_object(None, Obj::Other); + Some(()) + } + 0x012D => r.u16(6).map(|h| cv.select(wmf_handle(h))), + 0x01F0 => r.u16(6).map(|h| cv.delete_object(u32::from(h))), + // STRETCHDIB: rop(4) usage(2) srcH srcW ySrc xSrc destH destW yDest xDest, then the DIB. + 0x0F43 => { + let rop = r.u32(6).unwrap_or(0); + dib_blit(cv, r, 28, rop, [i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], [i16s(r, 18), i16s(r, 16), i16s(r, 14), i16s(r, 12)]) + } + 0x0B41 => dib_stretchblt(cv, r), + 0x0940 => dib_bitblt(cv, r), + _ => Some(()), + }; +} + +/// A 16-bit WMF handle as a canvas object index: stock objects are `0x8000 | index`, which the +/// canvas reads with the 32-bit stock flag. +fn wmf_handle(h: u16) -> u32 { + let h = u32::from(h); + if h & 0x8000 != 0 { (h & 0x7FFF) | 0x8000_0000 } else { h } +} + +/// An i16 field as f64; 0 past the end. +fn i16s(r: Bytes, off: usize) -> f64 { + r.i16(off).map_or(0.0, f64::from) +} + +/// CREATEPENINDIRECT: style, width (x, y), colour. Only the width's x component is used. +fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> { + let style = r.u16(6)?; + let width = r.i16(8)?; + let color = r.u32(12)?; + cv.add_object(None, Obj::Pen(Pen { style: u32::from(style), width: f64::from(width), color: colorref(color) })); + Some(()) +} + +/// CREATEBRUSHINDIRECT: style, colour, hatch (hatch is not drawn). +fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> { + let style = r.u16(6)?; + let color = r.u32(8)?; + cv.add_object(None, Obj::Brush(Brush { style: u32::from(style), color: colorref(color) })); + Some(()) +} + +/// (x, y) from a Y-then-X pair stored at `off`. +fn xy16(r: Bytes, off: usize) -> Option

{ + Some((f64::from(r.i16(off + 2)?), f64::from(r.i16(off)?))) +} + +/// Rectangle stored as bottom, right, top, left (the order MS-WMF uses for RECTANGLE and ELLIPSE). +fn rect16(r: Bytes, off: usize) -> Option<[f64; 4]> { + let (b, rr, t, l) = (r.i16(off)?, r.i16(off + 2)?, r.i16(off + 4)?, r.i16(off + 6)?); + Some([f64::from(l), f64::from(t), f64::from(rr), f64::from(b)]) +} + +/// ARC, PIE and CHORD: end Y, end X, start Y, start X, then the bounding rectangle (bottom, right, top, left). +fn arc16(r: Bytes) -> Option<([f64; 4], P, P)> { + let end = xy16(r, 6)?; + let start = xy16(r, 10)?; + let bounds = rect16(r, 14)?; + Some((bounds, start, end)) +} + +/// A count of 16-bit points, then the points (POLYLINE, POLYGON). +fn counted_points(r: Bytes) -> Option> { + let n = r.u16(6)? as usize; + r.tail(8)?.points(0, n, false) +} + +/// POLYPOLYGON: polygon count, one 16-bit point count per polygon, then all points. +fn polypolygon(r: Bytes) -> Option> { + let n = r.u16(6)? as usize; + let mut counts = Vec::with_capacity(n); + for i in 0..n { + counts.push(r.u16(i.checked_mul(2)?.checked_add(8)?)? as usize); + } + let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?; + if total > MAX_PATH_OPS { + return None; + } + let pts = r.tail(n.checked_mul(2)?.checked_add(8)?)?.points(0, total, false)?; + Some(geom::subpaths(&pts, &counts, true)) +} + +/// Whether a DIBBITBLT or DIBSTRETCHBLT record carries a bitmap: without one it is exactly +/// `(function >> 8) + 3` words long (MS-WMF 2.3.1.2, 2.3.1.3). +fn has_bitmap(func: u16, r: Bytes) -> bool { + r.0.len() / 2 > usize::from(func >> 8) + 3 +} + +/// META_DIBBITBLT: rop(4), ySrc, xSrc, then height, width, yDest, xDest and the DIB at 22. The +/// bitmap-less form has a reserved word before the height, so its fields sit 2 bytes later; it, or +/// a source-less raster operation, fills the destination. +fn dib_bitblt(cv: &mut Canvas, r: Bytes) -> Option<()> { + let rop = r.u32(6)?; + if !has_bitmap(0x0940, r) { + cv.fill_rop([i16s(r, 22), i16s(r, 20), i16s(r, 18), i16s(r, 16)], rop); + return Some(()); + } + let dst = [i16s(r, 20), i16s(r, 18), i16s(r, 16), i16s(r, 14)]; + if Canvas::sourceless(rop) { + cv.fill_rop(dst, rop); + return Some(()); + } + let src = [i16s(r, 12), i16s(r, 10), dst[2], dst[3]]; + dib_blit(cv, r, 22, rop, dst, src) +} + +/// META_DIBSTRETCHBLT: rop(4), srcH, srcW, ySrc, xSrc, then destH, destW, yDest, xDest and the DIB at +/// 26. The bitmap-less form has a reserved word after xSrc, so the destination sits 2 bytes later; +/// it, or a source-less raster operation, fills the destination. +fn dib_stretchblt(cv: &mut Canvas, r: Bytes) -> Option<()> { + let rop = r.u32(6)?; + if !has_bitmap(0x0B41, r) { + cv.fill_rop([i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], rop); + return Some(()); + } + let dst = [i16s(r, 24), i16s(r, 22), i16s(r, 20), i16s(r, 18)]; + if Canvas::sourceless(rop) { + cv.fill_rop(dst, rop); + return Some(()); + } + let src = [i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)]; + dib_blit(cv, r, 26, rop, dst, src) +} + +/// A DIB at `at`, stretched from source rectangle `src` into `dst` (both in logical units, pixels for `src`). +/// The raster operation `rop` decides how the pixels combine (see [`dib::prepare`]). +fn dib_blit(cv: &mut Canvas, r: Bytes, at: usize, rop: u32, dst: [f64; 4], src: [f64; 4]) -> Option<()> { + let dib = r.tail(at)?; + let n = dib::info_len(dib)?; + let info = dib.slice(0, n)?; + let bits = dib.tail(n)?; + let mut img = dib::decode(Bytes(info), bits, false)?; + dib::prepare(&mut img, dib::Blend::Rop(rop)); + let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?; + cv.bitmap(dst, img); + Some(()) +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index ccb98ea1..6b662887 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] deckcraft-geom = { workspace = true } deckcraft-color = { workspace = true } +deckcraft-metafile = { workspace = true } deckcraft-model = { workspace = true } deckcraft-fonts = { workspace = true } deckcraft-text = { workspace = true } diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 09e1baa4..3071e943 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -99,11 +99,20 @@ fn duotone_rgb(a: &PictureAdjust) -> Option<(deckcraft_color::Rgba, deckcraft_co Some((rgb(d, deckcraft_color::Rgba::BLACK), rgb(l, deckcraft_color::Rgba::WHITE))) } -/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF) to straight RGBA. +/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF; WMF and EMF drawn at +/// [`crate::metafile::DEFAULT_SIDE`]) to straight RGBA. pub fn decode(bytes: &[u8]) -> Option { + decode_at(bytes, crate::metafile::DEFAULT_SIDE) +} + +/// [`decode`], with a metafile drawn with its longer side `side` px. +fn decode_at(bytes: &[u8], side: u32) -> Option { if bytes.len() > 512 << 20 { return None; } + if crate::metafile::is_metafile(bytes) { + return crate::metafile::rasterize(bytes, side); + } let img = image::load_from_memory(bytes).ok()?; let (w, h) = (img.width(), img.height()); if w == 0 || h == 0 || (w as u64) * (h as u64) > 400_000_000 { @@ -131,13 +140,13 @@ pub fn decode_for(bytes: &Arc>, want: f64, adj: &PictureAdjust) -> Optio }) { return Some(pm); } - let Some(mut img) = decode(bytes) else { + let target = 1u32 << bucket; + let Some(mut img) = decode_at(bytes, target) else { with(|c| { c.bad.insert(addr, bytes.clone()); }); return None; }; - let target = 1u32 << bucket; let (w, h) = img.dimensions(); if w.max(h) > target.max(16) && w.max(h) > 4096.min(target * 2) { let k = target as f64 / w.max(h) as f64; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4e8058ad..bc457b67 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -12,6 +12,7 @@ mod colr; mod compound; mod images; +mod metafile; mod morph_path; mod morph_text; mod paint; diff --git a/crates/render/src/metafile.rs b/crates/render/src/metafile.rs new file mode 100644 index 00000000..8afcfb25 --- /dev/null +++ b/crates/render/src/metafile.rs @@ -0,0 +1,105 @@ +//! Windows Metafile and Enhanced Metafile pictures (`image/x-wmf`, `image/x-emf`): parsed by +//! `deckcraft-metafile` and drawn as vectors into a bitmap of the size the slide needs, so they go +//! through the same crop, tile and recolour path as any other picture. + +use std::sync::Arc; + +use deckcraft_metafile::PlacedItem; +use kurbo::{Affine, Rect, Shape as _}; +use vello_cpu::{Pixmap, RenderContext, Resources, peniko}; + +/// Longest side of a metafile drawn without a target size (export, thumbnails of the picture). +pub const DEFAULT_SIDE: u32 = 1024; +/// Largest side drawn. +const MAX_SIDE: u32 = 8192; + +/// Is `bytes` a WMF or EMF file? +pub fn is_metafile(bytes: &[u8]) -> bool { + deckcraft_metafile::is_metafile(bytes) +} + +/// Draw the metafile `bytes` into straight RGBA with its longer side `side` px (transparent where +/// it draws nothing). `None` when it doesn't parse or has no size. +pub fn rasterize(bytes: &[u8], side: u32) -> Option { + let mut pic = deckcraft_metafile::parse(bytes).ok()?; + if !pic.has_size() { + return None; + } + let side = side.clamp(16, MAX_SIDE) as f64; + let (pw, ph) = (pic.width as f64, pic.height as f64); + let k = side / pw.max(ph); + let w = (pw * k).round().clamp(1.0, MAX_SIDE as f64) as u16; + let h = (ph * k).round().clamp(1.0, MAX_SIDE as f64) as u16; + let pixmaps: Vec>> = pic.items.iter_mut().map(|it| it.take_pixels().and_then(|(w, h, px)| premul(w, h, &px))).collect(); + let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let r = Rect::new(0.0, 0.0, w as f64, h as f64); + ctx.push_clip_layer(&r.to_path(0.1)); + for it in pic.place(r, [0.0; 4]) { + match it { + PlacedItem::Path { path, fill, stroke, even_odd } => { + if let Some(f) = fill { + ctx.set_paint(rgba(f)); + ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); + ctx.fill_path(&path); + } + // GDI pens: round caps and joins; a cosmetic (zero-width) pen is one pixel. + if let Some((s, sw)) = stroke { + ctx.set_paint(rgba(s)); + ctx.set_stroke(kurbo::Stroke::new(sw.max(1.0)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } + } + PlacedItem::Bitmap { index, rect } => { + let Some(Some(pm)) = pixmaps.get(index) else { continue }; + let (bw, bh) = (pm.width().max(1) as f64, pm.height().max(1) as f64); + ctx.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm.clone()), + sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium), + }); + ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / bw, rect.height() / bh)); + ctx.fill_rect(&rect); + ctx.reset_paint_transform(); + } + } + } + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.pop_layer(); + ctx.flush(); + let mut px = vec![0u8; w as usize * h as usize * 4]; + if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut px) { + ctx.render(pm, &mut Resources::new()); + } + // Premultiplied to straight. + for p in px.as_chunks_mut::<4>().0 { + let a = p[3] as u32; + if a != 0 && a != 255 { + for c in p.iter_mut().take(3) { + *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + image::RgbaImage::from_raw(w as u32, h as u32, px) +} + +fn rgba(c: [u8; 4]) -> peniko::Color { + peniko::Color::from_rgba8(c[0], c[1], c[2], c[3]) +} + +/// Straight RGBA rows to a premultiplied pixmap. +fn premul(w: u32, h: u32, px: &[u8]) -> Option> { + if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 || px.len() < w as usize * h as usize * 4 { + return None; + } + let data = px + .as_chunks::<4>() + .0 + .iter() + .take(w as usize * h as usize) + .map(|p| { + let a = p[3] as u16; + let m = |c: u8| ((c as u16 * a + 127) / 255) as u8; + vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] } + }) + .collect(); + Some(Arc::new(Pixmap::from_parts(data, w as u16, h as u16))) +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index adcc4497..3ee10e66 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1075,3 +1075,73 @@ fn every_warp_preset_renders_text_inside_the_slide() { assert!(x0 <= x1 && y0 <= y1, "{name}: no ink"); } } + +/// A placeable WMF, 2 × 1 inch (2880 × 1440 twips): a solid red brush and a rectangle over the +/// left half, nothing on the right. +fn half_red_wmf() -> Vec { + fn rec(func: u16, params: &[i16]) -> Vec { + let mut v = ((3 + params.len()) as u32).to_le_bytes().to_vec(); + v.extend(func.to_le_bytes()); + for p in params { + v.extend(p.to_le_bytes()); + } + v + } + // CREATEBRUSHINDIRECT (solid, COLORREF 0x000000FF = red), SELECTOBJECT 0, RECTANGLE (bottom, + // right, top, left). + let body = [rec(0x02FC, &[0, 0xFF, 0, 0]), rec(0x012D, &[0]), rec(0x041B, &[1440, 1440, 0, 0]), rec(0, &[])].concat(); + let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec(); + f.extend(0u16.to_le_bytes()); + for v in [0i16, 0, 2880, 1440] { + f.extend(v.to_le_bytes()); + } + f.extend(1440u16.to_le_bytes()); + f.extend(0u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + let total = (f.len() + 18 + body.len()) / 2; + for v in [1u16, 9, 0x300] { + f.extend(v.to_le_bytes()); + } + f.extend((total as u32).to_le_bytes()); + f.extend(16u16.to_le_bytes()); + f.extend(0u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + f.extend(body); + f +} + +#[test] +fn metafile_pictures_draw_their_vectors() { + let mut p = Presentation::default(); + let media = p.add_media("image1.wmf", "image/x-wmf", half_red_wmf()); + let fill = deckcraft_model::style::PictureFill { media, ..Default::default() }; + let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + for scale in [0.5, 1.0, 3.0] { + let img = render_slide(&p, 0, &RenderOpts { scale, ..Default::default() }); + let at = |x: f64, y: f64| img.pixel((x * scale) as u32, (y * scale) as u32); + let left = at(200.0, 200.0); + assert!(left[0] > 200 && left[1] < 60 && left[2] < 60, "left half red at {scale}: {left:?}"); + let right = at(400.0, 200.0); + assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "right half empty at {scale}: {right:?}"); + } +} + +#[test] +fn broken_metafiles_draw_nothing_and_never_panic() { + let wmf = half_red_wmf(); + let mut cases: Vec> = (0..wmf.len()).step_by(3).map(|n| wmf[..n].to_vec()).collect(); + cases.push(vec![1, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF]); + let mut huge = wmf.clone(); + huge[10] = 0xFF; + huge[11] = 0x7F; + cases.push(huge); + for bytes in cases { + let mut p = Presentation::default(); + let media = p.add_media("image1.emf", "image/x-emf", bytes); + let fill = deckcraft_model::style::PictureFill { media, ..Default::default() }; + let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let _ = render_slide(&p, 0, &RenderOpts { scale: 0.25, ..Default::default() }); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2bf1d288..519a118c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -38,7 +38,7 @@ PowerPoint opens and saves; Viewer = opens only). |---|---|---|---| | PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 | | SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted | -| EMF / WMF | yes | **no** | Common in decks made on Windows: blank picture | +| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, bitmaps); text records inside are skipped | | HEIC | yes (macOS) | no | | | Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` | | WMA | yes (Windows) | embedded, not playable | | @@ -93,7 +93,7 @@ the round trip even though DeckCraft doesn't model it. embedded fonts, every transition and animation) in a corpus repo, with import → render → export → re-import checks in CI and a PowerPoint reference render for each (owner runs PowerPoint). 2. The round-trip losses above (#70, #74, #77). -3. EMF/WMF and SVG rendering. +3. SVG rendering, and text inside EMF/WMF. ## Revision history diff --git a/docs/gaps.md b/docs/gaps.md index a13383a3..4ab640aa 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -20,7 +20,7 @@ revision history. | 1 | PPTX never tested against a corpus of real decks | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) | | 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file | | 3 | PPTX round trip drops comment threads, media options, rewind (#70, #74, #77) | File format | B | 3–6 | [file-format-parity.md](file-format-parity.md) | -| 4 | EMF/WMF and SVG pictures don't render | File format | B | 8–14 | [file-format-parity.md](file-format-parity.md) | +| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 8–14 | [file-format-parity.md](file-format-parity.md) | | 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) | | 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) | | 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) | @@ -75,14 +75,14 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Impact:** a deck opened and saved silently loses review state and show behaviour. - **Estimate:** 3–6 h. **Beta blocker.** -### EMF/WMF and SVG pictures don't render +### SVG pictures don't render - **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF, - TIFF, BMP); nothing reads EMF, WMF or SVG. Decks from Windows often hold EMF (pasted charts, - Visio, logos); PowerPoint's Insert Icons stores SVG with a PNG fallback. -- **Impact:** blank pictures in real decks; SVG icons show only their raster fallback, and SVG files - can't be inserted. -- **Estimate:** 8–14 h (an SVG renderer through `resvg`-style crates, an EMF/WMF record player for - the common records). **Beta blocker** for EMF. + TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens + and bitmaps; text records are skipped). Nothing reads SVG; PowerPoint's Insert Icons stores SVG + with a PNG fallback. +- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. Text + drawn inside an EMF/WMF is missing. +- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates); EMF text records 2–4 h. ### Template, slide-show and macro variants are saved as plain `.pptx` - **Evidence:** `crates/pptx/src/write/mod.rs` always writes the diff --git a/docs/roadmap.md b/docs/roadmap.md index da275737..97953034 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,7 +16,7 @@ points and lead the beta plan. | Core workflow | Works end to end? | Evidence | Hours to full pass | |---|---|---|---| | Build a deck from scratch (theme, layouts, titles and bullets, shapes, pictures from file), save, close and reopen it | yes | Commands for all of it; `.deckcraft` and `.pptx` round-trip tests (`crates/pptx/tests/roundtrip.rs`, `crates/format`); AutoRecover. Close discards edits without asking (#60, PR #75); image *paste* fails on Windows/Linux (#53), though insert-from-file works | 1–3 | -| Open a colleague's `.pptx`, edit it, save it back as `.pptx` that PowerPoint opens | partial (not blocking) | The reader is lenient and keeps unknown objects. Files open in PowerPoint without repair (checked 2026-10-06). But only generated decks have been tested; EMF/WMF pictures render blank; comment threads, media options and rewind are lost (#70, #74, #77) | 31–55 | +| Open a colleague's `.pptx`, edit it, save it back as `.pptx` that PowerPoint opens | partial (not blocking) | The reader is lenient and keeps unknown objects. Files open in PowerPoint without repair (checked 2026-10-06). But only generated decks have been tested; text inside EMF/WMF pictures is missing; comment threads, media options and rewind are lost (#70, #74, #77) | 31–55 | | Present: full-screen show with transitions, animations, media, presenter view with notes, next slide and timer | yes | 48/48 transitions, 64 effects, triggers, builds, media playback, presenter window. Presenter view isn't placed on the second display automatically (drag it there) | 3–5 | | Add business content: tables, charts with their data, simple diagrams | partial (not blocking) | Tables with styles, merge/split, all D or P. Charts insert with the common types and edit data in the Chart Data dialog (P0 partial); SmartArt is basic (pre-drawn shapes) | 12–20 | | Share the deck: PDF of slides, notes or handouts; images | yes | `file.export` PDF with text layer, links, bookmarks, handouts 1–9; PNG/JPEG. Artwork is raster in the PDF; no direct print (print the PDF) | 10–16 | diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index eb0227d9..b9bb409b 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -47,6 +47,8 @@ pub const TABLE: &[(&str, Class)] = &[ ("isobmff", Class::Layer(1)), ("media", Class::Layer(2)), ("color", Class::Layer(0)), + // WMF/EMF reader (ported from WordCraft). + ("metafile", Class::Layer(0)), ("model", Class::Layer(1)), ("fonts", Class::Layer(1)), ("text", Class::Layer(2)), From 55a53f8ecb9d38a067cb79bc5f09b049fbe66a6f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 19:05:48 -0400 Subject: [PATCH 30/49] Fonts: missing fonts draw as PowerPoint draws them, and Windows family names resolve (#322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Text: a missing font is drawn in Calibri, as PowerPoint does Co-Authored-By: Claude Opus 5.5 (1M context) * Fonts: Windows family names find their faces; PowerPoint's twins for missing fonts "Segoe UI Semibold", "Arial Black" and the like are the faces' Windows (GDI) family names; a family's installed files now load together; Liberation Sans and Serif draw as Arial and Times New Roman when missing, as PowerPoint draws them. Co-Authored-By: Claude Opus 5.5 (1M context) * Fonts: Office cloud fonts (Aptos…) keep their open substitute Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ATTRIBUTION.md | 2 + crates/fonts/src/fontdb.rs | 135 ++++++++++++++++-- crates/fonts/src/lib.rs | 63 +++++++- .../fonts/tests/fixtures/legacy-regular.ttf | Bin 0 -> 864 bytes .../fonts/tests/fixtures/legacy-semibold.ttf | Bin 0 -> 944 bytes .../fonts/tests/fixtures/make_legacy_font.py | 55 +++++++ crates/text/src/lib.rs | 2 +- 7 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 crates/fonts/tests/fixtures/legacy-regular.ttf create mode 100644 crates/fonts/tests/fixtures/legacy-semibold.ttf create mode 100644 crates/fonts/tests/fixtures/make_legacy_font.py diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index b0d7747e..9ca8e5e8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -28,6 +28,8 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene | `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests | | `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures | | `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests | +| `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests | +| `crates/fonts/tests/fixtures/legacy-regular.ttf` | DeckCraft contributors | the same family's one-glyph Regular face, built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests | | `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font | ## Bundled through dependencies diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index d9c9ece7..3a28ce1d 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -52,6 +52,53 @@ pub fn substitutes(family: &str) -> &'static [&'static str] { } } +/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with +/// them breaks into the same lines. +fn has_metric_twin(family: &str) -> bool { + matches!( + family.to_ascii_lowercase().as_str(), + "calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier" + ) +} + +/// The installed family PowerPoint draws a missing `family` with instead of Calibri: Liberation +/// Sans and Serif as Arial and Times New Roman (observed in PowerPoint; Liberation Mono, Carlito, +/// Caladea, Arimo and Tinos are not mapped and draw in Calibri), and Windows' standard font +/// substitutes (Arial CE, Times New Roman CYR…). +fn office_twin(family: &str) -> Option<&'static str> { + let f = family.to_ascii_lowercase(); + Some(match f.as_str() { + "liberation sans" | "helvetica" | "helv" => "Arial", + "liberation serif" | "times" | "tms rmn" => "Times New Roman", + _ => { + let base = f + .strip_suffix(" baltic") + .or(f.strip_suffix(" ce")) + .or(f.strip_suffix(" cyr")) + .or(f.strip_suffix(" greek")) + .or(f.strip_suffix(" tur"))?; + match base { + "arial" => "Arial", + "courier new" => "Courier New", + "times new roman" => "Times New Roman", + _ => return None, + } + } + }) +} + +/// Office's downloadable (cloud) families: PowerPoint fetches them when a document uses them, so +/// they draw as themselves there, not as Calibri. Without them DeckCraft keeps their closest open +/// substitute ([`substitutes`]; Aptos as Inter). +fn is_office_cloud_font(family: &str) -> bool { + let f = family.to_ascii_lowercase(); + f.starts_with("aptos") || ["bierstadt", "grandview", "seaford", "skeena", "tenorite"].iter().any(|c| f.starts_with(c)) +} + +/// The family PowerPoint draws a document's Latin text in when the font it names isn't installed, +/// whatever the theme, `panose` or `pitchFamily` say. +pub const MISSING_FONT_FAMILY: &str = "Calibri"; + #[derive(Clone)] enum FontBytes { Static(&'static [u8]), @@ -65,6 +112,10 @@ pub struct FontFace { pub family: String, /// Typographic style name (e.g. "Semibold", "Italic"). pub style: String, + /// The Windows (GDI) family and style names (`name` IDs 1 and 2) when the family differs from + /// the typographic one: "Segoe UI Semibold" / "Regular" for Segoe UI Semibold. Documents name + /// fonts this way. + pub legacy: Option<(String, String)>, /// usWeightClass-style weight (400 = regular). pub weight: f32, pub italic: bool, @@ -253,6 +304,11 @@ pub struct FontDb { /// scan, so what they find doesn't depend on what ran before them. #[cfg(not(target_arch = "wasm32"))] cataloged: std::sync::OnceLock<()>, + /// Families (lower case) whose cataloged files have all been loaded: a family's files load + /// together, so a lookup never settles for the one face another name loaded ("Segoe UI + /// Semibold" loads a face of the family "Segoe UI"). + #[cfg(not(target_arch = "wasm32"))] + loaded_families: Mutex>, /// System fallback state: enabled, and characters no system font covers. #[cfg(not(target_arch = "wasm32"))] sys: Mutex, @@ -316,6 +372,16 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { Some((family, style)) } +/// A face's Windows (GDI) family and style (`name` IDs 1 and 2) when that family differs from the +/// typographic `family`. +fn legacy_names(f: &skrifa::FontRef<'_>, family: &str) -> Option<(String, String)> { + let legacy = name(f, &[StringId::FAMILY_NAME])?; + if legacy.eq_ignore_ascii_case(family) { + return None; + } + Some((legacy, name(f, &[StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()))) +} + /// (family, style) of every face in the font file at `path`, reading only its table directories /// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. A /// variable font is cataloged under its default style; its named instances appear once it loads. @@ -362,8 +428,13 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { font.extend_from_slice(&u32::to_be_bytes(v)); } font.extend_from_slice(&table); - face_names(&skrifa::FontRef::new(&font).ok()?) + let f = skrifa::FontRef::new(&font).ok()?; + let (family, style) = face_names(&f)?; + // Cataloged under its Windows family too, so "Segoe UI Semibold" finds the file. + let legacy = legacy_names(&f, &family); + Some(std::iter::once((family, style)).chain(legacy)) }) + .flatten() .collect() } @@ -491,10 +562,12 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords .map(|v| v >= 0.5) .or(axis(b"slnt").map(|v| v.abs() > 0.1)) .unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style)); + let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { None }; Some(FontFace { id: NEXT_ID.fetch_add(1, Ordering::Relaxed), family, style, + legacy, weight, italic, upem: m.units_per_em.max(1) as f64, @@ -571,6 +644,8 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] cataloged: std::sync::OnceLock::new(), #[cfg(not(target_arch = "wasm32"))] + loaded_families: Mutex::new(std::collections::HashSet::new()), + #[cfg(not(target_arch = "wasm32"))] sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), } } @@ -676,6 +751,8 @@ impl FontDb { /// Returns the number of faces cataloged. #[cfg(not(target_arch = "wasm32"))] pub fn load_system_fonts(&self) -> usize { + // Families load again from the new catalog when next looked up. + self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear(); // The first scan, or a rescan once it is done (never both at once). let mut first = None; self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs())); @@ -756,17 +833,35 @@ impl FontDb { .unwrap_or_else(last_resort_face) } + /// The face a document's text in `family` is drawn with, as PowerPoint draws it: the family + /// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri, + /// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial, + /// see `office_twin`); any other missing family is drawn in Calibri (or Carlito). + /// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks. + pub fn document_face(&self, family: &str, style: &str) -> Arc { + if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) { + return self.face(family, style); + } + if let Some(twin) = office_twin(family) { + return self.face(twin, style); + } + self.face(MISSING_FONT_FAMILY, style) + } + fn try_family(&self, family: &str, style: &str) -> Option> { - if let Some(f) = self.find(family, style) { - return Some(f); - } + // Every installed file of the family first, so the best style is among the candidates. The + // lock is held while the files load, so another thread looking up the family waits for + // them rather than finding none and falling back to another family. #[cfg(not(target_arch = "wasm32"))] - if self.load_cataloged(family) - && let Some(f) = self.find(family, style) { - return Some(f); + let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()); + let key = family.to_lowercase(); + if !loaded.contains(&key) { + self.load_cataloged(family); + loaded.insert(key); + } } - None + self.find(family, style) } /// Is `family` available (loaded, or installed on the system)? @@ -782,7 +877,9 @@ impl FontDb { } pub(crate) fn is_loaded(&self, family: &str) -> bool { - self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family)) + self.read_faces() + .iter() + .any(|f| f.family.eq_ignore_ascii_case(family) || f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family))) } /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. @@ -837,11 +934,27 @@ impl FontDb { return self.instance(family, style); } let faces = self.read_faces(); - let cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); + let mut cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); + let ns = norm(style); + if cands.is_empty() { + // A Windows family name ("Segoe UI Semibold", "Arial Black"): its faces, matched by + // their Windows style ("Regular", "Bold"…). + cands = faces.iter().filter(|f| f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family))).collect(); + if let Some(f) = cands.iter().find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == ns)) { + return Some((*f).clone()); + } + // Bold of a family that is itself a weight (Semibold, Light): the face, made bolder + // by the caller, as Windows does, rather than a face of another weight. + if let Some(f) = cands + .iter() + .find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == norm(if style_italic(style) { "Italic" } else { "Regular" }))) + { + return Some((*f).clone()); + } + } if cands.is_empty() { return None; } - let ns = norm(style); if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) { return Some((*f).clone()); } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 8caff942..16be2ca5 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -14,7 +14,9 @@ pub mod script; mod shape; pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets}; -pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs}; +pub use fontdb::{ + FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs, +}; pub use harfrust::Feature; use harfrust::Tag; pub use kurbo::BezPath; @@ -182,6 +184,65 @@ mod tests { assert!(!shape(&f, "Hello", &[], |c| c).is_empty()); } + #[test] + fn windows_family_names_find_their_faces() { + // Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's + // typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way. + let check = |db: &FontDb| { + assert!(db.has_family("DeckCraft Legacy Semibold")); + for style in ["Regular", "Bold"] { + let f = db.face("DeckCraft Legacy Semibold", style); + assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}"); + } + assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri"); + // The typographic name still works. + assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold"); + }; + // Loaded directly… + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1); + check(&db); + // …and found by the system font scan under its Windows name, then loaded. + #[cfg(not(target_arch = "wasm32"))] + { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + check(&db); + // Looking up the Windows name loads the Semibold file only; a later lookup of the + // family's Regular must still load and pick the Regular file, not settle for Semibold. + let db = FontDb::with_font_dirs(vec![dir]); + db.set_system_fallback(false); + assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold"); + let regular = db.face("DeckCraft Legacy", "Regular"); + assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0)); + } + } + + #[test] + fn missing_document_fonts_are_drawn_in_calibri() { + // PowerPoint draws a missing family in Calibri, whatever its panose or the theme says. + let db = FontDb::global(); + for missing in ["Qqq Nonexistent", "Avenir Next Qqq", "", "Arimo Qqq"] { + let f = db.document_face(missing, "Bold"); + assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}"); + } + // PowerPoint's own twins and Windows' substitutes, when the font itself is missing. + for (missing, twin) in + [("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")] + { + if !db.has_family(missing) { + assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}"); + } + } + // Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks. + // So do Office's cloud fonts, which PowerPoint downloads and draws as themselves. + for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] { + assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}"); + } + } + #[test] fn office_fonts_have_substitutes() { assert!(substitutes("Calibri").contains(&"Carlito")); diff --git a/crates/fonts/tests/fixtures/legacy-regular.ttf b/crates/fonts/tests/fixtures/legacy-regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ab660c655706868389399fb1b8bfea1857c091b8 GIT binary patch literal 864 zcmah{ze^)Q7=1evb5ZpCK|rw@LBvKQ{x~cI2}UbHQILRz4!fJ=FwqcKFpXG=rG6UaE?KmYrA)m|_tGV@uwR`fvzmclO9@IT}A{%zHXSRw^5lmxd&}iklDn*vB&_YM^ zWqU-vSuVmf(l&!s_|worG2&~K&d!!}BzXhVDao01u-bI#T8)ggc3XE3^RAA0>+tsc zexiGz+jCyU^?Qa#Tx~dr{0_RvKX3Yj%wmZ?^hP*V=ZRBInKZFho+>OvSl}me5tbN3 z5Mh(|(-X%i&N9BcHCNHOKrQ-t?N(R({9aL48fDd}TlYvLBLg2UwlIeZG$#j~vq&vZ i=Aw#~5bvQ#UTNx9P5t$~M;hGU{~_Tce-(2+2z&rY{e5Bp literal 0 HcmV?d00001 diff --git a/crates/fonts/tests/fixtures/legacy-semibold.ttf b/crates/fonts/tests/fixtures/legacy-semibold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..44b0ec7c74927e1bf70163cde2a468c9687777b7 GIT binary patch literal 944 zcmah{&1(}u9DO^pP2*=1=|Mehp@^4K>4)@CkkAMs1*L%!=s~Bun-99%gfxZbCLW~6 z9_rP23;VaF-JpPYyXOSe!=| z&-9e;H7YZBX|Z6hw-(Ek?xt`CkFaMwIaWWgIM4b=#n9haYN<-wTMnzr?|7v!uCF(u zU?r?>N2)c5oA6XWJkV`kUq=xkd~9F|EhtpbX2)e}RWctPvJzR0I@ZxZ#CKdtqT9rZ eIm7ws?5UybVgKL#u6Fy^7nl5LgF}CjlfWPG&xlw6 literal 0 HcmV?d00001 diff --git a/crates/fonts/tests/fixtures/make_legacy_font.py b/crates/fonts/tests/fixtures/make_legacy_font.py new file mode 100644 index 00000000..2753f927 --- /dev/null +++ b/crates/fonts/tests/fixtures/make_legacy_font.py @@ -0,0 +1,55 @@ +"""Build the legacy-name test font: DeckCraft Legacy Semibold (contributor-original, CC0-1.0). + +Usage: python make_legacy_font.py legacy-semibold.ttf legacy-regular.ttf (needs fontTools) + +legacy-semibold.ttf: typographic family "DeckCraft Legacy", style "Semibold" (name IDs 16/17, +weight 600); Windows family "DeckCraft Legacy Semibold", style "Regular" (name IDs 1/2), as fonts +like Segoe UI Semibold are named. legacy-regular.ttf: the family's Regular ("DeckCraft Legacy", +"Regular", weight 400), named only by IDs 1/2 as Segoe UI's regular file is. Glyphs: .notdef and +a square for "A". +""" +import sys + +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen + + +def square(): + pen = TTGlyphPen(None) + pen.moveTo((100, 0)) + pen.lineTo((100, 700)) + pen.lineTo((600, 700)) + pen.lineTo((600, 0)) + pen.closePath() + return pen.glyph() + + +def build(path, names, weight): + fb = FontBuilder(1000, isTTF=True) + fb.setupGlyphOrder([".notdef", "A"]) + fb.setupCharacterMap({ord("A"): "A"}) + fb.setupGlyf({".notdef": TTGlyphPen(None).glyph(), "A": square()}) + fb.setupHorizontalMetrics({".notdef": (500, 0), "A": (700, 100)}) + fb.setupHorizontalHeader(ascent=800, descent=-200) + fb.setupNameTable(names) + fb.setupOS2(usWeightClass=weight, sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200) + fb.setupPost() + fb.save(path) + + +build(sys.argv[1], { + "familyName": "DeckCraft Legacy Semibold", + "styleName": "Regular", + "typographicFamily": "DeckCraft Legacy", + "typographicSubfamily": "Semibold", + "uniqueFontIdentifier": "DeckCraft Legacy Semibold", + "fullName": "DeckCraft Legacy Semibold", + "psName": "DeckCraftLegacy-Semibold", +}, 600) +build(sys.argv[2], { + "familyName": "DeckCraft Legacy", + "styleName": "Regular", + "uniqueFontIdentifier": "DeckCraft Legacy Regular", + "fullName": "DeckCraft Legacy", + "psName": "DeckCraftLegacy-Regular", +}, 400) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 7144c551..69e65997 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -261,7 +261,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> Styl let family = resolve::font_family(ctx, r); let bold = r.bold.unwrap_or(false); let italic = r.italic.unwrap_or(false); - let face = FontDb::global().face(&family, style_name(bold, italic)); + let face = FontDb::global().document_face(&family, style_name(bold, italic)); let fake_bold = bold && face.weight < 550.0; let fake_italic = italic && !face.italic; let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0); From 8159db8ba55d95f7890bdb795d61d465e5c38bc2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 19:06:46 -0400 Subject: [PATCH 31/49] Render tests: the arch top bound allows Linux's fallback font (#323) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/render/src/tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 3ee10e66..63bbddbd 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1037,7 +1037,8 @@ fn wordart_arch_up_lifts_the_middle_of_the_line() { // at the box top and the ends sit clearly lower. let (al, am, ar) = (top_near(&atops, ax0 + 20).unwrap(), top_near(&atops, (ax0 + ax1) / 2).unwrap(), top_near(&atops, ax1 - 20).unwrap()); assert!(al > am + 10 && ar > am + 10, "arched {al} {am} {ar}"); - assert!((80..=110).contains(&ay0), "arch top {ay0}"); + // At the box top (y = 100), give or take the font: Linux's fallback face reaches 79. + assert!((70..=110).contains(&ay0), "arch top {ay0}"); } #[test] From 85366b1a62478884c9c850975853e169aba2ff10 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 20:34:54 -0400 Subject: [PATCH 32/49] Fonts: Office's downloaded fonts are used; opening a file says which fonts are missing (#326) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/file.rs | 10 ++-- crates/engine/src/fonts.rs | 65 ++++++++++++++++++++++++ crates/engine/src/lib.rs | 5 ++ crates/engine/src/tests.rs | 24 +++++++++ crates/fonts/src/fontdb.rs | 12 ++++- crates/ui-egui/src/lib.rs | 40 +++++++++++++++ crates/ui-egui/src/toast.rs | 94 +++++++++++++++++++++++++++++++++++ 7 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/fonts.rs create mode 100644 crates/ui-egui/src/toast.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 55aac8bd..cab58058 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -79,7 +79,10 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result { pub fn add_opened(s: &mut Session, name: &str, path: Option, p: Presentation) -> usize { let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()); - s.add_document(DocState::new(p, path, title)) + let missing_fonts = crate::fonts::missing_fonts(&p); + let mut st = DocState::new(p, path, title); + st.missing_fonts = missing_fonts; + s.add_document(st) } #[cfg(not(target_arch = "wasm32"))] @@ -89,7 +92,8 @@ fn open(s: &mut Session, p: &Value) -> Result { let doc = open_presentation(path, &bytes)?; let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes); let i = add_opened(s, path, native.then(|| path.to_string()), doc); - Ok(json!({"document": i, "slides": s.doc()?.doc.slides.len()})) + let st = s.doc()?; + Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts})) } #[cfg(target_arch = "wasm32")] fn open(_s: &mut Session, _p: &Value) -> Result { @@ -101,7 +105,7 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result { let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?; let doc = open_presentation(name, &data)?; let i = add_opened(s, name, None, doc); - Ok(json!({"document": i})) + Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts})) } /// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`). diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs new file mode 100644 index 00000000..fcbd2feb --- /dev/null +++ b/crates/engine/src/fonts.rs @@ -0,0 +1,65 @@ +//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text +//! draws in a stand-in; see `FontDb::document_face`). + +use deckcraft_fonts::FontDb; +use deckcraft_model::resolve::{self, Ctx}; +use deckcraft_model::text::{Bullet, TextBody}; +use deckcraft_model::{Presentation, Shape, ShapeKind}; + +/// The font families the slides' text is drawn in (theme fonts resolved, bullet fonts included) +/// that aren't installed or bundled, sorted, without duplicates (case-insensitive). +pub fn missing_fonts(p: &Presentation) -> Vec { + let db = FontDb::global(); + let mut out: Vec = Vec::new(); + for family in used_fonts(p) { + if !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) { + out.push(family); + } + } + out.sort_by_key(|f| f.to_lowercase()); + out +} + +/// The families the slides' text runs and bullets are drawn in. +fn used_fonts(p: &Presentation) -> Vec { + let mut out: Vec = Vec::new(); + let mut add = |f: String| { + let f = f.trim().to_string(); + if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) { + out.push(f); + } + }; + for slide in &p.slides { + let Some(ctx) = Ctx::for_slide(p, slide) else { continue }; + deckcraft_model::walk(&slide.shapes, &mut |s, _| { + if let Some(body) = &s.text { + body_fonts(&ctx, s, body, &mut add); + } + if let ShapeKind::Table(t) = &s.kind { + for cell in t.rows.iter().flat_map(|r| &r.cells) { + body_fonts(&ctx, s, &cell.text, &mut add); + } + } + }); + } + out +} + +fn body_fonts(ctx: &Ctx, s: &Shape, body: &TextBody, add: &mut dyn FnMut(String)) { + for para in &body.paragraphs { + let has_text = para.runs.iter().any(|r| !r.text.trim().is_empty()); + if !has_text { + continue; + } + for run in para.runs.iter().filter(|r| !r.text.trim().is_empty()) { + let r = resolve::run(ctx, s, para, &run.props); + add(resolve::font_family(ctx, &r)); + } + let pp = resolve::para(ctx, s, para); + if matches!(pp.bullet, Some(Bullet::Char { .. })) + && let Some(f) = pp.bullet_font.as_deref() + { + add(ctx.master.theme.font(f)); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2d2d2ac..b1775c2d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,6 +9,7 @@ pub mod cmd; pub mod connect; +pub mod fonts; pub mod guard; pub mod keys; pub mod links; @@ -141,6 +142,9 @@ pub struct DocState { pub interaction: Option, pub uid: u64, pub title: String, + /// Fonts the file's text uses that aren't installed, found when it was opened (empty for new + /// documents); the app mentions them once. + pub missing_fonts: Vec, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -158,6 +162,7 @@ impl DocState { interaction: None, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), title, + missing_fonts: Vec::new(), } } pub fn is_dirty(&self) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aefd0b7..57037300 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -504,6 +504,30 @@ fn hyperlink_on_a_word_or_shape_links_it() { assert!(s.doc().unwrap().current_slide().unwrap().shapes[0].click.is_some(), "a selected shape gets the link as its click action"); } +/// A .pptx (base64) whose first slide's title is set in `font`. +fn deck_in_font(font: &str) -> String { + let mut p = deckcraft_model::Presentation::default(); + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + let mut body = deckcraft_model::text::TextBody::from_text("Hello"); + body.paragraphs[0].runs[0].props.font = Some(font.into()); + slide.shapes[0].text = Some(body); + crate::cmd::base64_encode(&crate::cmd::file::save_bytes(&p, "pptx").unwrap()) +} + +#[test] +fn opening_a_file_lists_the_fonts_it_uses_that_are_missing() { + let mut s = Session::with_new(); + let r = s.execute("file.openBytes", &json!({"name": "a.pptx", "data": deck_in_font("Qqq Nonexistent Sans")})).unwrap(); + assert_eq!(r["missingFonts"], json!(["Qqq Nonexistent Sans"])); + assert_eq!(s.doc().unwrap().missing_fonts, vec!["Qqq Nonexistent Sans".to_string()]); + // A font that is there (bundled or installed) isn't listed; new documents list nothing. + let have = deckcraft_fonts::FontDb::global().face(deckcraft_fonts::FALLBACK_FAMILY, "Regular").family.clone(); + let r = s.execute("file.openBytes", &json!({"name": "b.pptx", "data": deck_in_font(&have)})).unwrap(); + assert_eq!(r["missingFonts"], json!([])); + s.execute("file.new", &json!({})).unwrap(); + assert!(s.doc().unwrap().missing_fonts.is_empty()); +} + #[test] fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() { // Flips don't mirror text, so a click lands on the character drawn under it. diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 3a28ce1d..31cb9a18 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -450,6 +450,12 @@ pub fn system_font_dirs() -> Vec { dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into)); if let Some(h) = &home { dirs.push(h.join("Library/Fonts")); + // Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them. + dirs.push(h.join("Library/Group Containers/UBF8T346G9.Office/FontCache/4/CloudFonts")); + } + // Fonts Office installs inside its apps. + for app in ["Microsoft PowerPoint", "Microsoft Word", "Microsoft Excel"] { + dirs.push(std::path::PathBuf::from(format!("/Applications/{app}.app/Contents/Resources/DFonts"))); } // Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font. for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] { @@ -464,7 +470,11 @@ pub fn system_font_dirs() -> Vec { let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); dirs.push(root.join("Fonts")); if let Some(l) = std::env::var_os("LOCALAPPDATA") { - dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + let l = std::path::PathBuf::from(l); + dirs.push(l.join("Microsoft\\Windows\\Fonts")); + // Fonts Microsoft Office downloads on demand (Aptos…), used where Office put them; + // DeckCraft never ships or needs them. + dirs.push(l.join("Microsoft\\FontCache\\4\\CloudFonts")); } } else { dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bef64a4f..a02218c7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ pub mod status; pub mod textures; pub mod theme; pub mod thumbs; +pub mod toast; pub mod widgets; use std::sync::mpsc::{Receiver, Sender}; @@ -181,6 +182,10 @@ pub struct SlideApp { /// Audio/video playback (editor control bar, slide show, `media.*` commands). pub media: media::MediaHost, pub status: Option<(String, f64)>, + /// A brief notice over the bottom of the window (missing fonts on opening a file). + pub toast: Option, + /// Documents (by uid) whose missing fonts have been mentioned. + fonts_noted: std::collections::HashSet, pub perf: Perf, /// Synthetic input events (control channel `ui.click` etc.), injected one per frame. pub synthetic: Vec, @@ -224,6 +229,8 @@ impl SlideApp { show: None, media: media::MediaHost::new(audio_out), status: None, + toast: None, + fonts_noted: Default::default(), perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 }, synthetic: vec![], canvas_rect: None, @@ -730,6 +737,8 @@ impl SlideApp { } } }); + toast::note_missing_fonts(self); + toast::show(self, &ctx); dialogs::show(self, &ctx); menus::palette(self, &ctx); for url in std::mem::take(&mut self.pending_urls) { @@ -889,6 +898,37 @@ mod tests { sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default() } + #[test] + fn opening_a_file_with_missing_fonts_shows_a_toast_once() { + use egui_kittest::kittest::Queryable; + let mut p = deckcraft_model::Presentation::default(); + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + let mut body = deckcraft_model::text::TextBody::from_text("Hello"); + body.paragraphs[0].runs[0].props.font = Some("Qqq Nonexistent Sans".into()); + slide.shapes[0].text = Some(body); + let data = deckcraft_engine::cmd::base64_encode(&deckcraft_engine::cmd::file::save_bytes(&p, "pptx").unwrap()); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + assert!(harness.state().toast.is_none(), "a new presentation has nothing to say"); + harness.state_mut().session.execute("file.openBytes", &json!({"name": "a.pptx", "data": data})).unwrap(); + harness.run_steps(2); + let text = harness.state().toast.as_ref().map(|t| t.text.clone()).unwrap_or_default(); + assert!(text.starts_with("Qqq Nonexistent Sans isn't installed"), "{text}"); + assert!(harness.query_by_label_contains("Qqq Nonexistent Sans isn't installed").is_some()); + // Closed, it stays closed for that document. + harness.get_by_label("Close").click(); + harness.run_steps(2); + assert!(harness.state().toast.is_none()); + harness.run_steps(2); + assert!(harness.state().toast.is_none()); + } + /// #17: Home created a new blank presentation instead of going to the start screen. #[test] fn home_shows_the_start_screen_over_the_open_presentation() { diff --git a/crates/ui-egui/src/toast.rs b/crates/ui-egui/src/toast.rs new file mode 100644 index 00000000..0dcfb057 --- /dev/null +++ b/crates/ui-egui/src/toast.rs @@ -0,0 +1,94 @@ +//! Brief notices over the window's bottom edge: shown once, gone after a few seconds or when +//! closed. Used to say a presentation's fonts aren't installed when it is first opened. + +use egui::{Align2, CornerRadius, Margin, Stroke, vec2}; + +use crate::SlideApp; +use crate::icons::{self, Icon}; +use crate::theme::{self, Tokens}; + +/// How long a toast stays, in milliseconds. +const SHOW_MS: f64 = 8000.0; + +pub struct Toast { + pub text: String, + pub at: f64, +} + +/// The notice for fonts a presentation uses that aren't installed, or `None` when all are. +pub fn missing_fonts_message(fonts: &[String]) -> Option { + let names = match fonts { + [] => return None, + [a] => a.clone(), + [a, b] => format!("{a} and {b}"), + [a, b, c] => format!("{a}, {b} and {c}"), + [a, b, c, rest @ ..] => format!("{a}, {b}, {c} and {} more", rest.len()), + }; + let (it, them) = if fonts.len() == 1 { ("isn't", "it") } else { ("aren't", "them") }; + Some(format!("{names} {it} installed on this computer. Text that uses {them} is shown in a similar font.")) +} + +/// Toast the active presentation's missing fonts, once per opened document. +pub fn note_missing_fonts(app: &mut SlideApp) { + let Some(d) = app.session.active() else { return }; + if !app.fonts_noted.insert(d.uid) { + return; + } + if let Some(text) = missing_fonts_message(&d.missing_fonts) { + app.toast = Some(Toast { text, at: crate::now_ms() }); + } +} + +pub fn show(app: &mut SlideApp, ctx: &egui::Context) { + let Some(toast) = &app.toast else { return }; + if crate::now_ms() - toast.at > SHOW_MS { + app.toast = None; + return; + } + let t = Tokens::get(ctx); + let text = toast.text.clone(); + let mut close = false; + egui::Area::new(egui::Id::new("toast")).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -44.0)).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Frame::NONE + .fill(t.card) + .stroke(Stroke::new(1.0, t.card_border)) + .corner_radius(CornerRadius::same(6)) + .inner_margin(Margin::symmetric(14, 10)) + .shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: t.shadow }) + .show(ui, |ui| { + ui.set_max_width(520.0); + ui.horizontal(|ui| { + ui.add(egui::Label::new(egui::RichText::new(&text).font(theme::font(13.0)).color(t.text)).wrap()); + let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Close")); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), r.shrink(3.0), Icon::Close, t.text_dim, false); + if resp.on_hover_text("Close").clicked() { + close = true; + } + }); + }); + }); + if close { + app.toast = None; + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_message_names_the_fonts() { + let f = |v: &[&str]| missing_fonts_message(&v.iter().map(|s| s.to_string()).collect::>()); + assert_eq!(f(&[]), None); + assert_eq!(f(&["Aptos"]).unwrap(), "Aptos isn't installed on this computer. Text that uses it is shown in a similar font."); + assert!(f(&["Aptos", "Sora"]).unwrap().starts_with("Aptos and Sora aren't installed")); + assert!(f(&["A", "B", "C"]).unwrap().starts_with("A, B and C aren't")); + assert!(f(&["A", "B", "C", "D", "E"]).unwrap().starts_with("A, B, C and 2 more aren't")); + } +} From 75bfd1da3eb318bb117105cf46d0cda1c214668e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 21:40:45 -0400 Subject: [PATCH 33/49] Embedded objects draw their preview picture, including old VML previews (#328) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/pptx/src/read/shapes.rs | 18 ++++++- crates/pptx/tests/ole.rs | 98 ++++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 7 +++ crates/render/src/tests.rs | 31 +++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 crates/pptx/tests/ole.rs diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 05a74388..7cb6635a 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -356,7 +356,8 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S let mut pics = vec![]; e.find_all("blip", &mut pics); pics.into_iter().find_map(|b| dml::media_ref(imp, part, b)) - }; + } + .or_else(|| gd.and_then(|g| g.find("oleObj")).and_then(|o| vml_preview(imp, part, o.attr("spid")?))); let label = if let Some(o) = gd.and_then(|g| g.find("oleObj")) { o.attr("progId").or(o.attr("name")).unwrap_or("Object").to_string() } else if uri.ends_with("/diagram") { @@ -370,6 +371,21 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S s } +/// The preview picture of an old-style embedded object (`p:oleObj spid` with no `p:pic`): the +/// `v:imagedata` of the legacy VML drawing shape with that id, as PowerPoint 2007 wrote them. +fn vml_preview(imp: &mut Imp, part: &Part, spid: &str) -> Option { + for rel in part.rels.items.iter().filter(|r| r.kind == "vmlDrawing" && !r.external) { + let Some(doc) = imp.pkg.xml(&rel.target) else { continue }; + let mut shapes = vec![]; + doc.root.find_all("shape", &mut shapes); + let Some(img) = shapes.into_iter().find(|s| s.attr("id") == Some(spid)).and_then(|s| s.find("imagedata")) else { continue }; + let Some(id) = img.attr("o:relid").or(img.attr("r:id")) else { continue }; + let target = imp.pkg.rels(&rel.target).get(id).filter(|r| !r.external).map(|r| r.target.clone())?; + return imp.media_for(&target); + } + None +} + /// SmartArt: read the pre-drawn shapes of its drawing part as a group. fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option { let rel_ids = gd.child("relIds")?; diff --git a/crates/pptx/tests/ole.rs b/crates/pptx/tests/ole.rs new file mode 100644 index 00000000..5c0bc1db --- /dev/null +++ b/crates/pptx/tests/ole.rs @@ -0,0 +1,98 @@ +//! Embedded (OLE) objects and their preview pictures, as other versions of PowerPoint write them. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_model::{ShapeKind, defaults}; + +fn png() -> Vec { + let mut out = Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30]))) + .write_to(&mut out, image::ImageFormat::Png) + .expect("png"); + out.into_inner() +} + +/// An exported deck with `parts` added (or replaced) and `edit` applied to every part's text. +fn with_parts(parts: &[(&str, Vec)], edit: impl Fn(&str, String) -> String) -> Vec { + let base = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export"); + let mut z = zip::ZipArchive::new(Cursor::new(base)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let name = f.name().to_string(); + if parts.iter().any(|(n, _)| *n == name) { + continue; + } + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + if name.ends_with(".xml") || name.ends_with(".rels") { + d = edit(&name, String::from_utf8(d).expect("utf8")).into_bytes(); + } + out.start_file(name, o).expect("start"); + out.write_all(&d).expect("write"); + } + for (name, data) in parts { + out.start_file(*name, o).expect("start"); + out.write_all(data).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +/// PowerPoint 2007 style: `p:oleObj spid` with no `p:pic`; the preview is the `v:imagedata` of the +/// VML shape with that id in the slide's legacy drawing part. +fn ole_deck(spid_in_vml: &str) -> Vec { + let vml = format!( + r#" + + +"# + ); + let vml_rels = r#" +"#; + let frame = r#""#; + with_parts( + &[ + ("ppt/drawings/vmlDrawing1.vml", vml.into_bytes()), + ("ppt/drawings/_rels/vmlDrawing1.vml.rels", vml_rels.as_bytes().to_vec()), + ("ppt/media/preview.png", png()), + ("ppt/media/other.png", vec![1, 2, 3]), + ("ppt/embeddings/oleObject1.bin", vec![0; 16]), + ], + |name, s| { + if name == "ppt/slides/slide1.xml" { + s.replacen("", &format!("{frame}"), 1) + } else if name == "ppt/slides/_rels/slide1.xml.rels" { + s.replacen( + "", + r#""#, + 1, + ) + } else { + s + } + }, + ) +} + +fn object_preview(bytes: &[u8]) -> Option> { + let p = deckcraft_pptx::import(bytes).expect("import"); + let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object"); + match &s.kind { + ShapeKind::Opaque { preview, label, .. } => { + assert_eq!(label, "PowerPoint.Slide.8"); + preview.and_then(|m| p.media(m)).map(|m| m.data.to_vec()) + } + k => panic!("{k:?}"), + } +} + +#[test] +fn an_old_ole_object_shows_its_vml_preview() { + assert_eq!(object_preview(&ole_deck("_x0000_s681987")), Some(png()), "the VML shape with the object's spid"); +} + +#[test] +fn an_ole_object_without_a_matching_vml_shape_has_no_preview() { + assert_eq!(object_preview(&ole_deck("_x0000_s1")), None); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index bc457b67..aae393fd 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -717,6 +717,13 @@ impl Renderer { paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); ctx.pop_layer(); } + // An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its + // preview picture, stretched over the frame, as PowerPoint draws it. + ShapeKind::Opaque { preview: Some(media), .. } => { + ctx.set_transform(m); + let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() }; + paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None); + } ShapeKind::Media(mc) => { ctx.set_transform(m); let bounds = Rect::new(0.0, 0.0, w, h); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 63bbddbd..ee3b7047 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1146,3 +1146,34 @@ fn broken_metafiles_draw_nothing_and_never_panic() { let _ = render_slide(&p, 0, &RenderOpts { scale: 0.25, ..Default::default() }); } } + +#[test] +fn an_object_deckcraft_doesnt_model_shows_its_preview_picture() { + // An embedded OLE object (or an unknown graphic) draws its preview stretched over its frame, + // as PowerPoint does; with no preview it draws nothing. + let png = { + let mut out = std::io::Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 2, image::Rgb([200, 20, 20]))) + .write_to(&mut out, image::ImageFormat::Png) + .unwrap(); + out.into_inner() + }; + for with_preview in [true, false] { + let mut p = Presentation::default(); + let media = p.add_media("image1.png", "image/png", png.clone()); + let sh = Shape { + id: ShapeId(600), + xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), + kind: ShapeKind::Opaque { xml: String::new(), preview: with_preview.then_some(media), label: "Package".into() }, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + for (x, y) in [(110, 110), (300, 200), (490, 290)] { + let px = img.pixel(x, y); + let red = px[0] > 180 && px[1] < 60 && px[2] < 60; + assert_eq!(red, with_preview, "({x}, {y}) {px:?}"); + } + assert!(img.pixel(520, 200)[1] > 200, "nothing outside the frame"); + } +} From f94f3c12d189653621a37ecf31efaddab9f9a9dc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 21:47:05 -0400 Subject: [PATCH 34/49] Canvas: objects off the slide show and can be grabbed around it, as in PowerPoint (#270) (#329) PowerPoint's Normal view draws shapes, pictures and text that sit partly or wholly off the slide on the area around it, at full strength, with their effects, master and layout graphics included; the slide show, exports and thumbnails clip to the slide. DeckCraft clipped everything to the slide in the editor too. - render: `slide_extent`/`layout_extent` estimate how far a slide's drawing reaches (boxes, outlines, shadow, glow, reflection); `render_slide_drawn` also reports what a render actually drew (text spilling out of its box, table rows that grew); `pasteboard_region` picks the pixels the editor renders: the slide plus what reaches off it and is in view, rounded to 256 px tiles so scrolling rarely re-renders. A slide with nothing off it renders exactly as before. - ui-egui: the canvas texture covers that region (slide and master views); it re-renders only when the slide, zoom or the region changes, and a second time only if text drew beyond the estimate. - Hit testing, selection, marquee and moves already worked in slide space; tests now cover them off the slide. Co-authored-by: Claude Opus 5.5 (1M context) --- ROADMAP.md | 1 + crates/engine/src/tests.rs | 39 ++++ crates/render/src/extent.rs | 329 +++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 70 ++++++- crates/render/src/table.rs | 15 +- crates/render/src/tests.rs | 91 +++++++++ crates/ui-egui/src/canvas.rs | 47 ++++- crates/ui-egui/src/lib.rs | 106 +++++++++++ crates/ui-egui/src/textures.rs | 83 ++++++++- docs/ui-parity.md | 4 +- 10 files changed, 758 insertions(+), 27 deletions(-) create mode 100644 crates/render/src/extent.rs diff --git a/ROADMAP.md b/ROADMAP.md index c74bbdae..bc987aff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 57037300..2693853b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -567,3 +567,42 @@ fn inserted_hyperlinks_look_like_links() { s.execute("format.underline", &json!({})).unwrap(); assert_eq!(s.execute("format.state", &json!({})).unwrap()["underline"], false); } + +/// #270: shapes off the slide (in the editor's pasteboard) are hit, selected, moved and marqueed +/// like any other; one can be dragged back onto the slide or out of it. +#[test] +fn shapes_off_the_slide_can_be_selected_and_moved() { + let mut s = session(); + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + let off = s.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); + let half = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [-60.0, 300.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap(); + // Inserting off the slide keeps the box where it was asked for. + assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 1000.0); + s.execute("edit.select", &json!({"ids": []})).unwrap(); + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + // Hover and click wholly off the slide. + assert!(matches!(s.hit_test(deckcraft_geom::Point::new(1060.0, 140.0), 3.0), Some(tools::Hit::Shape { id, .. }) if id.0 as u64 == off)); + s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 1060.0, 140.0)).unwrap(); + assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::>(), vec![off]); + // Drag it further off, then back onto the slide. + s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 1110.0, 160.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 1110.0, 160.0)).unwrap(); + let v = s.execute("shape.inspect", &json!({"id": off})).unwrap(); + assert_eq!((v["box"]["x"].as_f64(), v["box"]["y"].as_f64()), (Some(1050.0), Some(120.0))); + s.pointer(ev(PointerKind::Down, 1110.0, 160.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 500.0, 160.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 500.0, 160.0)).unwrap(); + assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 440.0); + // The off-slide half of a straddling shape is hit too. + s.pointer(ev(PointerKind::Down, -30.0, 340.0)).unwrap(); + s.pointer(ev(PointerKind::Up, -30.0, 340.0)).unwrap(); + assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::>(), vec![half]); + // A marquee started on the pasteboard selects what it encloses there. + s.pointer(ev(PointerKind::Down, -100.0, 250.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 80.0, 420.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 80.0, 420.0)).unwrap(); + assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::>(), vec![half]); +} diff --git a/crates/render/src/extent.rs b/crates/render/src/extent.rs new file mode 100644 index 00000000..d5d9a272 --- /dev/null +++ b/crates/render/src/extent.rs @@ -0,0 +1,329 @@ +//! The pasteboard: what the editor draws around the slide. +//! +//! PowerPoint's Normal view draws shapes, pictures and text that sit partly or wholly off the slide +//! on the area around it, at full strength and with their effects (master and layout graphics +//! too), so they can be seen and grabbed. Slide shows, exports and thumbnails clip to the slide. +//! +//! The editor renders only what it needs: the slide, grown to take in the drawing that spills off +//! it and is in view ([`pasteboard_region`]). A slide with nothing off it costs what it always did. + +use deckcraft_geom::Xfrm; +use deckcraft_model::resolve::{self, Ctx}; +use deckcraft_model::style::{Effects, Line}; +use deckcraft_model::{Presentation, Shape, ShapeKind}; +use kurbo::Rect; + +use crate::MAX_SIDE; + +/// Largest distance (points) a shape's outline or effects are taken to reach beyond its box: +/// hostile files can't blow the pasteboard up. +const MAX_SPILL: f64 = 2000.0; + +/// The visible area is rounded out to tiles of this many pixels, so scrolling over a pasteboard +/// that is wider than the window re-renders only when a new tile comes into view. +pub const TILE: i64 = 256; + +/// How far (points) a shape's outline and effects reach beyond its box: half the outline and its +/// arrowheads, the outer shadow (offset, blur and scale), the glow and the reflection. +pub(crate) fn spill(line: Option<&Line>, effects: Option<&Effects>, w: f64, h: f64) -> f64 { + let fin = |v: f64| if v.is_finite() { v.abs() } else { 0.0 }; + let mut s: f64 = 0.0; + if let Some(l) = line + && l.fill.as_ref().is_some_and(|f| !f.is_none()) + { + // Arrowheads reach up to a few line widths past the end point. + s = s.max(fin(l.width.unwrap_or(0.75)) * 3.0 + 1.0); + } + if let Some(e) = effects { + let big = fin(w).max(fin(h)); + if let Some(sh) = &e.outer_shadow { + let grow = (fin(sh.sx).max(fin(sh.sy)) - 1.0).max(0.0) * big; + s = s.max(fin(sh.dist) + fin(sh.blur) + grow); + } + if let Some(g) = &e.glow { + s = s.max(fin(g.radius)); + } + if let Some(r) = &e.reflection { + s = s.max(fin(h) + fin(r.dist)); + } + } + s.min(MAX_SPILL) +} + +/// Slide-space box a top-level shape draws into, its spill included. +fn shape_box(rctx: &Ctx, s: &Shape) -> Option { + if s.hidden { + return None; + } + let x: Xfrm = resolve::xfrm(rctx, s); + if !x.is_finite() { + return None; + } + let b = match &s.kind { + ShapeKind::Ink { strokes } => { + let mut pts = strokes.iter().flat_map(|k| k.points.iter()).filter(|(px, py, _)| px.is_finite() && py.is_finite()); + let (px, py, _) = pts.next()?; + let r = pts.fold(Rect::new(*px, *py, *px, *py), |r, (px, py, _)| r.union_pt(kurbo::Point::new(*px, *py))); + let w = strokes.iter().map(|k| k.width).filter(|w| w.is_finite()).fold(0.0_f64, f64::max).min(MAX_SPILL); + return Some(r.inflate(w, w)); + } + _ => x.bounds(), + }; + let (line, _) = resolve::line(rctx, s); + let (fx, _) = resolve::effects(rctx, s); + let d = spill(Some(&line), fx.as_ref(), x.w, x.h); + Some(b.inflate(d, d)) +} + +fn union_boxes<'a>(rctx: &Ctx, shapes: impl IntoIterator, into: Rect) -> Rect { + shapes.into_iter().filter_map(|s| shape_box(rctx, s)).filter(|r| r.is_finite()).fold(into, |a, r| a.union(r)) +} + +fn slide_rect(pres: &Presentation) -> Rect { + Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height) +} + +/// What slide `index` draws, in slide space: at least the slide, grown by every shape (and master +/// and layout graphic) that reaches off it. Estimated from the shapes' boxes, outlines and effects; +/// text spilling out of its box is found while rendering ([`crate::render_slide_drawn`]). +pub fn slide_extent(pres: &Presentation, index: usize) -> Rect { + let page = slide_rect(pres); + let Some(slide) = pres.slides.get(index) else { return page }; + let Some(rctx) = Ctx::for_slide(pres, slide) else { return page }; + let mut r = union_boxes(&rctx, &slide.shapes, page); + let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout); + if show_master { + let mctx = Ctx::for_master(pres, rctx.master); + r = union_boxes(&mctx, rctx.master.shapes.iter().filter(|s| s.ph.is_none()), r); + } + if show_layout && let Some(l) = rctx.layout { + let lctx = Ctx::for_layout(pres, rctx.master, l); + r = union_boxes(&lctx, l.shapes.iter().filter(|s| s.ph.is_none()), r); + } + r +} + +/// What a layout (or the master, `layout: None`) draws in Slide Master view, in slide space. +pub fn layout_extent(pres: &Presentation, master: usize, layout: Option) -> Rect { + let page = slide_rect(pres); + let Some(m) = pres.masters.get(master) else { return page }; + match layout.and_then(|i| m.layouts.get(i)) { + Some(l) => { + let mut r = union_boxes(&Ctx::for_layout(pres, m, l), &l.shapes, page); + if l.show_master_shapes { + r = union_boxes(&Ctx::for_master(pres, m), m.shapes.iter().filter(|s| s.ph.is_none()), r); + } + r + } + None => union_boxes(&Ctx::for_master(pres, m), &m.shapes, page), + } +} + +/// A rectangle of pixels on the editor canvas, relative to the slide's top-left corner. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Region { + pub x: i32, + pub y: i32, + pub w: u32, + pub h: u32, +} + +impl Region { + /// The slide itself, `w` × `h` pixels. + pub fn slide(w: u32, h: u32) -> Self { + Region { x: 0, y: 0, w: w.max(1), h: h.max(1) } + } + + fn edges(&self) -> (i64, i64, i64, i64) { + let (x, y) = (self.x as i64, self.y as i64); + (x, y, x + self.w as i64, y + self.h as i64) + } + + fn from_edges(x0: i64, y0: i64, x1: i64, y1: i64) -> Self { + let clamp = |v: i64| v.clamp(i32::MIN as i64, i32::MAX as i64) as i32; + let side = |a: i64, b: i64| (b - a).clamp(1, MAX_SIDE as i64) as u32; + Region { x: clamp(x0), y: clamp(y0), w: side(x0, x1), h: side(y0, y1) } + } + + /// Does this region cover all of `other`? + pub fn contains(&self, other: &Region) -> bool { + let (a, b) = (self.edges(), other.edges()); + a.0 <= b.0 && a.1 <= b.1 && a.2 >= b.2 && a.3 >= b.3 + } + + /// Is the region more than the slide (some of the pasteboard is drawn)? + pub fn beyond(&self, slide: (u32, u32)) -> bool { + *self != Region::slide(slide.0, slide.1) + } + + /// Render options' `offset` that puts the slide's top-left corner where it falls in the region. + pub fn offset(&self) -> (f64, f64) { + (-(self.x as f64), -(self.y as f64)) + } + + /// The region's size, for render options' `size`. + pub fn size(&self) -> (u32, u32) { + (self.w, self.h) + } +} + +/// `r` (slide space) in pixels at `scale` pixels per point, rounded outwards; `None` when not finite. +fn to_px(r: Rect, scale: f64) -> Option<(i64, i64, i64, i64)> { + let lim = i32::MAX as f64 / 2.0; + let px = |v: f64| if v.is_finite() { Some(v.clamp(-lim, lim)) } else { None }; + Some(( + px((r.x0 * scale).floor())? as i64, + px((r.y0 * scale).floor())? as i64, + px((r.x1 * scale).ceil())? as i64, + px((r.y1 * scale).ceil())? as i64, + )) +} + +/// The pixels the editor renders: the slide (`slide_px`, its size in pixels at `scale` pixels per +/// point) plus the part of `extent` (slide space, from [`slide_extent`] or what a render drew) that +/// falls within `visible`, the canvas's area relative to the slide's top-left corner. The visible +/// area is rounded out to whole [`TILE`]s. +pub fn pasteboard_region(slide_px: (u32, u32), scale: f64, extent: Rect, visible: Region) -> Region { + let page = Region::slide(slide_px.0, slide_px.1); + if !(scale.is_finite() && scale > 0.0) { + return page; + } + let Some((ex0, ey0, ex1, ey1)) = to_px(extent, scale) else { return page }; + let (vx0, vy0, vx1, vy1) = visible.edges(); + let (vx0, vy0) = (vx0.div_euclid(TILE) * TILE, vy0.div_euclid(TILE) * TILE); + let (vx1, vy1) = ((vx1 + TILE - 1).div_euclid(TILE) * TILE, (vy1 + TILE - 1).div_euclid(TILE) * TILE); + let (cx0, cy0, cx1, cy1) = (ex0.max(vx0), ey0.max(vy0), ex1.min(vx1), ey1.min(vy1)); + if cx0 >= cx1 || cy0 >= cy1 { + return page; + } + let (px0, py0, px1, py1) = page.edges(); + Region::from_edges(cx0.min(px0), cy0.min(py0), cx1.max(px1), cy1.max(py1)) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + use deckcraft_model::Shape; + + fn black() -> deckcraft_model::ColorRef { + deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK) + } + + fn deck_with(x: f64, y: f64, w: f64, h: f64) -> Presentation { + let mut p = Presentation::default(); + let sl = std::sync::Arc::make_mut(&mut p.slides[0]); + sl.shapes.push(Shape { id: deckcraft_model::ShapeId(900), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() }); + p + } + + #[test] + fn a_slide_with_nothing_off_it_extends_no_further_than_the_slide() { + let p = deck_with(100.0, 100.0, 50.0, 50.0); + assert_eq!(slide_extent(&p, 0), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height)); + } + + #[test] + fn shapes_off_the_slide_grow_the_extent() { + let p = deck_with(-200.0, 50.0, 100.0, 80.0); + let e = slide_extent(&p, 0); + assert!(e.x0 <= -200.0 && e.x0 > -220.0, "{e:?}"); + assert_eq!(e.y0, 0.0); + assert_eq!(e.x1, p.slide_size.width); + // Missing slides are just the page. + assert_eq!(slide_extent(&p, 7), Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height)); + } + + #[test] + fn hidden_shapes_and_hostile_boxes_do_not_grow_it() { + let mut p = deck_with(-5000.0, 0.0, 10.0, 10.0); + std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap().hidden = true; + let page = Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height); + assert_eq!(slide_extent(&p, 0), page); + let p = deck_with(f64::NAN, 0.0, 10.0, 10.0); + assert_eq!(slide_extent(&p, 0), page); + let p = deck_with(f64::INFINITY, 0.0, 10.0, 10.0); + assert_eq!(slide_extent(&p, 0), page); + } + + #[test] + fn effects_count_towards_the_extent() { + let mut p = deck_with(10.0, -100.0, 100.0, 50.0); + let sh = std::sync::Arc::make_mut(&mut p.slides[0]).shapes.last_mut().unwrap(); + sh.effects = Some(Effects { + outer_shadow: Some(deckcraft_model::style::Shadow { + color: black(), + blur: 10.0, + dist: 20.0, + dir: 270.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + }), + ..Default::default() + }); + assert!(slide_extent(&p, 0).y0 <= -130.0); + assert_eq!( + spill( + None, + Some(&Effects { glow: Some(deckcraft_model::style::Glow { color: black(), radius: f64::MAX }), ..Default::default() }), + 1.0, + 1.0 + ), + MAX_SPILL + ); + } + + #[test] + fn master_graphics_off_the_slide_count_but_not_master_placeholders() { + let mut p = Presentation::default(); + let m = std::sync::Arc::make_mut(&mut p.masters[0]); + m.shapes.push(Shape { id: deckcraft_model::ShapeId(901), xfrm: Some(Xfrm::new(-150.0, 10.0, 100.0, 50.0)), ..Default::default() }); + assert!(slide_extent(&p, 0).x0 <= -150.0); + assert!(layout_extent(&p, 0, None).x0 <= -150.0); + assert!(layout_extent(&p, 0, Some(0)).x0 <= -150.0 || !p.masters[0].layouts[0].show_master_shapes); + } + + #[test] + fn the_region_is_the_slide_unless_something_off_it_is_in_view() { + let slide = (960, 540); + let page = Region::slide(960, 540); + let view = Region { x: -100, y: -50, w: 1160, h: 640 }; + let whole = Rect::new(0.0, 0.0, 960.0, 540.0); + assert_eq!(pasteboard_region(slide, 1.0, whole, view), page); + assert!(!pasteboard_region(slide, 1.0, whole, view).beyond(slide)); + // Off to the left, in view: the region reaches it. + let r = pasteboard_region(slide, 1.0, Rect::new(-80.0, 0.0, 960.0, 540.0), view); + assert_eq!((r.x, r.y, r.w, r.h), (-80, 0, 1040, 540)); + assert!(r.beyond(slide) && r.contains(&page)); + assert_eq!(r.offset(), (80.0, 0.0)); + // Far off and out of view (the view rounds out to tiles): only what's visible. + let r = pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, 960.0, 540.0), view); + assert_eq!(r.x, -256); + // Wholly out of view: the slide. + assert_eq!(pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, -4000.0, 540.0).union(whole), Region { x: 0, y: 0, w: 960, h: 540 }), page); + // Scale applies; nonsense falls back to the slide. + let r = pasteboard_region((480, 270), 0.5, Rect::new(-80.0, 0.0, 960.0, 540.0), view); + assert_eq!(r.x, -40); + assert_eq!(pasteboard_region(slide, f64::NAN, whole, view), page); + assert_eq!(pasteboard_region(slide, 1.0, Rect::new(f64::NAN, 0.0, 1.0, 1.0), view), page); + // Huge extents are capped. + let r = + pasteboard_region(slide, 1.0, Rect::new(-1e12, -1e12, 1e12, 1e12), Region { x: -1_000_000, y: -1_000_000, w: 2_000_000, h: 2_000_000 }); + assert!(r.w <= MAX_SIDE && r.h <= MAX_SIDE); + } + + #[test] + fn scrolling_within_a_tile_keeps_the_region() { + let slide = (2000, 1125); + let e = Rect::new(-500.0, 0.0, 960.0, 540.0); + let a = pasteboard_region(slide, 2.0, e, Region { x: -300, y: 0, w: 1000, h: 700 }); + let b = pasteboard_region(slide, 2.0, e, Region { x: -290, y: 10, w: 1000, h: 700 }); + assert_eq!(a, b); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index aae393fd..8f88024d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -11,6 +11,7 @@ mod chart; mod colr; mod compound; +mod extent; mod images; mod metafile; mod morph_path; @@ -40,6 +41,7 @@ use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2}; use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_cpu::{RenderContext, Resources, peniko}; +pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent}; pub use images::{decode as decode_image, gif_frame}; pub use morph_path::PathMorph; pub use morph_text::TextMorph; @@ -233,11 +235,14 @@ pub struct Renderer { /// The background of the slide (layout, master) being drawn, its placeholder colour and the /// slide's view transform: shapes filled with the background (`useBgFill`) show it through. bg: Option<(Fill, Option, Affine)>, + /// What the frame being drawn has painted so far, in output pixels: the editor learns from it + /// how far a slide's drawing (text spilling out of its box too) reaches off the slide. + drawn: Option, } impl Default for Renderer { fn default() -> Self { - Renderer { resources: Resources::new(), paths: Vec::new(), bg: None } + Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None } } } @@ -285,14 +290,38 @@ fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool { /// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a /// blank image (logged), never a crash. pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image { - let Some(slide) = pres.slides.get(index) else { return Image::default() }; - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide(pres, slide, index, opts)))) { - Ok(img) => img, + render_slide_drawn(pres, index, opts).0 +} + +/// [`render_slide`], also returning how far the drawing reached, in slide space (at least the +/// slide; off it, what the shapes, their effects and their text drew, the parts outside the output +/// included). The editor grows its pasteboard to it ([`pasteboard_region`]). +pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) -> (Image, Rect) { + let page = Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height); + let Some(slide) = pres.slides.get(index) else { return (Image::default(), page) }; + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RENDERER.with(|r| { + let mut r = r.borrow_mut(); + r.drawn = None; + let img = r.slide(pres, slide, index, opts); + (img, r.drawn.take()) + }) + })) { + Ok((img, drawn)) => { + let k = opts.scale; + let to_slide = + |d: Rect| Rect::new((d.x0 - opts.offset.0) / k, (d.y0 - opts.offset.1) / k, (d.x1 - opts.offset.0) / k, (d.y1 - opts.offset.1) / k); + let extent = match drawn { + Some(d) if k.is_finite() && k > 0.0 && to_slide(d).is_finite() => page.union(to_slide(d)), + _ => page, + }; + (img, extent) + } Err(_) => { log::error!("rendering slide {index} panicked; showing a blank slide"); RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); let (w, h) = output_size(pres, opts); - Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] } + (Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }, page) } } } @@ -472,6 +501,14 @@ impl Renderer { (ctx, w, h, view) } + /// Note that `r` (local space, placed by `m`) was drawn. + fn track(&mut self, m: Affine, r: Rect) { + let b = m.transform_rect_bbox(r); + if b.is_finite() { + self.drawn = Some(self.drawn.map_or(b, |d| d.union(b))); + } + } + fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields { SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts) } @@ -658,10 +695,21 @@ impl Renderer { self.shape(ctx, f, rctx, c, gm, depth + 1); } } - ShapeKind::Table(t) => table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields), - ShapeKind::Chart(c) => chart::draw(ctx, rctx, c, m, x.w, x.h), + ShapeKind::Table(t) => { + // Rows grow with their text: what the cells cover, borders included. + let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields); + self.track(m, cells.unwrap_or(Rect::new(0.0, 0.0, x.w, x.h)).inflate(2.0, 2.0)); + } + ShapeKind::Chart(c) => { + self.track(m, Rect::new(0.0, 0.0, x.w, x.h)); + chart::draw(ctx, rctx, c, m, x.w, x.h) + } ShapeKind::Ink { strokes } => { for sk in strokes { + let w = if sk.width.is_finite() { sk.width.abs() } else { 0.0 }; + for (px, py, _) in &sk.points { + self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w)); + } let mut bp = BezPath::new(); for (i, (px, py, _)) in sk.points.iter().enumerate() { if i == 0 { @@ -692,6 +740,8 @@ impl Renderer { let (fill, fill_ph) = resolve::fill(rctx, s); let (line, line_ph) = resolve::line(rctx, s); let (effects, fx_ph) = resolve::effects(rctx, s); + let reach = extent::spill(Some(&line), effects.as_ref(), w, h); + self.track(m, Rect::new(0.0, 0.0, w, h).inflate(reach, reach)); let is_pic = matches!(s.kind, ShapeKind::Picture { .. }); let outline = geo.outline(); // Placeholders in the editor: dashed outline and prompt, nothing else when empty. @@ -825,6 +875,12 @@ impl Renderer { let tr = geo.text_rect; if !body.is_empty() { let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false }); + let tm = text_transform(m, tr, l.rotation); + for b in l.lines.iter().filter_map(line_bounds) { + // Glyphs reach a little past their carets and line box (italics, swashes). + let pad = b.height() * 0.3; + self.track(tm, b.inflate(pad, pad)); + } match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) { Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w), None => draw_layout_with(ctx, &l, tr, m, &st.paras), diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index 61eea5b6..685df6f9 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -297,9 +297,21 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> { out } -pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) { +/// Draws the table; returns the box its cells cover (table space), which grows with their text. +#[allow(clippy::too_many_arguments)] +pub fn draw( + ctx: &mut RenderContext, + pres: &Presentation, + rctx: &Ctx, + t: &Table, + m: Affine, + _w: f64, + _h: f64, + fields: &dyn deckcraft_text::Fields, +) -> Option { let _ = pres; let rects = cell_rects(t, &row_heights(rctx, t, fields)); + let covered = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)); // Table backgrounds behind every cell: a file-defined style's, then the table's own fill. let style_bg = rctx .pres @@ -379,4 +391,5 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None); let _ = peniko::Fill::NonZero; } + covered } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ee3b7047..d9a5883e 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1177,3 +1177,94 @@ fn an_object_deckcraft_doesnt_model_shows_its_preview_picture() { assert!(img.pixel(520, 200)[1] > 200, "nothing outside the frame"); } } + +/// #270: a filled shape half off the slide's left edge, and one wholly below it. +fn off_slide_deck() -> Presentation { + let half = Shape { + id: ShapeId(2700), + xfrm: Some(Xfrm::new(-100.0, 100.0, 200.0, 100.0)), + style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), + ..Default::default() + }; + let below = Shape { + id: ShapeId(2701), + xfrm: Some(Xfrm::new(400.0, 600.0, 100.0, 60.0)), + style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent2)), + ..Default::default() + }; + deck_with(vec![half, below]) +} + +fn near(px: [u8; 4], c: deckcraft_color::Rgba) -> bool { + (px[0] as i32 - c.r as i32).abs() <= 3 && (px[1] as i32 - c.g as i32).abs() <= 3 && (px[2] as i32 - c.b as i32).abs() <= 3 && px[3] == 255 +} + +#[test] +fn shows_exports_and_thumbnails_clip_to_the_slide() { + let p = off_slide_deck(); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + assert_eq!((img.width, img.height), (960, 540)); + let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1); + // The on-slide half is there, nothing else is drawn outside the slide. + assert!(near(img.pixel(50, 150), a1), "{:?}", img.pixel(50, 150)); + assert_eq!(img.pixel(150, 150), [255, 255, 255, 255]); +} + +#[test] +fn the_editor_pasteboard_draws_shapes_off_the_slide() { + let p = off_slide_deck(); + let extent = slide_extent(&p, 0); + let region = pasteboard_region((960, 540), 1.0, extent, Region { x: -300, y: -200, w: 1560, h: 1000 }); + assert!(region.x <= -100 && region.y == 0 && region.y as i64 + region.h as i64 >= 660, "{region:?}"); + let opts = RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() }; + let (img, drawn) = render_slide_drawn(&p, 0, &opts); + assert_eq!((img.width, img.height), region.size()); + let at = |x: i32, y: i32| img.pixel((x - region.x) as u32, (y - region.y) as u32); + let a1 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1); + let a2 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent2); + // Both halves of the straddling shape, at full strength, and the shape below the slide. + assert!(near(at(-50, 150), a1), "{:?}", at(-50, 150)); + assert!(near(at(50, 150), a1), "{:?}", at(50, 150)); + assert!(near(at(450, 630), a2), "{:?}", at(450, 630)); + // The pasteboard itself stays clear (the UI's own colour shows through); the slide is white. + assert_eq!(at(-50, 50), [0, 0, 0, 0]); + assert_eq!(at(200, 300), [255, 255, 255, 255]); + // The drawing's reach, in slide space. + assert!(drawn.x0 <= -100.0 && drawn.y1 >= 660.0 && drawn.x1 >= 960.0, "{drawn:?}"); +} + +#[test] +fn master_graphics_off_the_slide_show_on_the_pasteboard() { + let mut p = Presentation::default(); + let m = std::sync::Arc::make_mut(&mut p.masters[0]); + m.shapes.push(Shape { + id: ShapeId(2702), + xfrm: Some(Xfrm::new(-150.0, 300.0, 100.0, 60.0)), + style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent3)), + ..Default::default() + }); + let region = pasteboard_region((960, 540), 1.0, slide_extent(&p, 0), Region { x: -300, y: -100, w: 1500, h: 800 }); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() }); + let a3 = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent3); + assert!(near(img.pixel((-100 - region.x) as u32, (330 - region.y) as u32), a3)); +} + +#[test] +fn text_spilling_out_of_its_box_below_the_slide_is_reported() { + let mut sh = Shape { id: ShapeId(2703), xfrm: Some(Xfrm::new(100.0, 500.0, 400.0, 20.0)), ..Default::default() }; + let mut t = TextBody::from_text("one\ntwo\nthree\nfour\nfive"); + for para in &mut t.paragraphs { + for r in &mut para.runs { + r.props.size = Some(40.0); + } + } + sh.text = Some(t); + let p = deck_with(vec![sh]); + // The box estimate doesn't see the text below the slide; the render does. + assert!(slide_extent(&p, 0).y1 <= 540.0 + 4.0); + let (_, drawn) = render_slide_drawn(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() }); + assert!(drawn.y1 > 600.0, "{drawn:?}"); + // A slide with nothing off it reaches just the slide. + let (_, drawn) = render_slide_drawn(&Presentation::default(), 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() }); + assert_eq!(drawn, Rect::new(0.0, 0.0, 960.0, 540.0)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d267f76c..886694f9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1,6 +1,10 @@ //! The slide canvas: the rendered slide, zoom and fit, pointer input routed to the engine's tools, //! and the overlays drawn on top (selection box and handles, smart guides, marquee, drawing //! previews, the text caret and text selection). +//! +//! As in PowerPoint's Normal view, shapes, pictures and text that sit partly or wholly off the +//! slide are drawn on the area around it (the pasteboard), where they can be selected and moved; +//! the slide show, exports and thumbnails clip to the slide. use deckcraft_engine::tools::{Hit, box_handles}; use deckcraft_engine::{Mods, PointerEvent, PointerKind, Target, ToolKind}; @@ -108,18 +112,26 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { crate::media::gif_repaint(ui.ctx(), &gifs); let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); let st = app.session.active(); + // One texture pixel in screen points, and the canvas in pixels from the slide's corner. + let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32); + let visible = visible_region(avail, slide_rect, pixel); let tex = match st.map(|d| (d.selection.target, d)) { Some((Target::Slides, d)) => { let idx = d.selection.slide; let doc = d.doc.clone(); - app.textures.slide(ui.ctx(), &doc, idx, px, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×) + app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×) } - Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px), - Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px), + Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible), + Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible), None => None, }; - if let Some(tex) = tex { - painter.image(tex.id(), slide_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + if let Some((tex, region)) = tex { + let at = Rect::from_min_size( + slide_rect.min + vec2(region.x as f32 * pixel.x, region.y as f32 * pixel.y), + vec2(region.w as f32 * pixel.x, region.h as f32 * pixel.y), + ); + app.canvas_region = Some(at); + painter.image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } app.perf.render_ms = app.textures.last_render_ms; media_frames(app, &painter, xf); @@ -157,6 +169,22 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { context_menu(app, &resp); } +/// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's +/// top-left corner. +fn visible_region(avail: Rect, slide: Rect, pixel: egui::Vec2) -> deckcraft_render::Region { + let px = |v: f32, k: f32| if v.is_finite() && k > 0.0 { (v / k) as f64 } else { 0.0 }; + let (x0, y0) = (px(avail.min.x - slide.min.x, pixel.x).floor(), px(avail.min.y - slide.min.y, pixel.y).floor()); + let (x1, y1) = (px(avail.max.x - slide.min.x, pixel.x).ceil(), px(avail.max.y - slide.min.y, pixel.y).ceil()); + let lim = i32::MAX as f64 / 4.0; + let (x0, y0) = (x0.clamp(-lim, lim), y0.clamp(-lim, lim)); + deckcraft_render::Region { + x: x0 as i32, + y: y0 as i32, + w: (x1 - x0).clamp(1.0, u32::MAX as f64) as u32, + h: (y1 - y0).clamp(1.0, u32::MAX as f64) as u32, + } +} + /// Play/pause on an animated GIF under the pointer or selected on its own. fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) { let Some(d) = app.session.active() else { return }; @@ -240,14 +268,17 @@ fn master_tex( master: usize, layout: Option, px: (u32, u32), -) -> Option { + visible: deckcraft_render::Region, +) -> Option<(egui::TextureHandle, deckcraft_render::Region)> { + let scale = px.0 as f64 / doc.slide_size.width.max(1.0); + let region = deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible); let img = deckcraft_render::render_layout( doc, master, layout, - &deckcraft_render::RenderOpts { scale: px.0 as f64 / doc.slide_size.width.max(1.0), edit: true, size: Some(px), ..Default::default() }, + &deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() }, ); - Some(ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR)) + Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region)) } fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a02218c7..0bd854bc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -193,6 +193,8 @@ pub struct SlideApp { pub canvas_rect: Option, pub canvas_scale: f32, pub slide_rect: Option, + /// Where the canvas texture (the slide and the pasteboard drawn around it) is on screen. + pub canvas_region: Option, pub palette: Option<(String, usize)>, /// Integrated title bar (macOS traffic lights over our chrome). pub integrated_titlebar: bool, @@ -236,6 +238,7 @@ impl SlideApp { canvas_rect: None, canvas_scale: 1.0, slide_rect: None, + canvas_region: None, palette: None, integrated_titlebar: false, home: None, @@ -978,4 +981,107 @@ mod tests { app.run("app.home", json!({})).unwrap(); assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway"); } + fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let app = harness.state_mut(); + // Zoomed out, the canvas shows plenty of pasteboard around the slide. + app.ui.zoom = Some(0.4); + app.session.prefs.snap_to_grid = false; + app.session.prefs.smart_guides = false; + harness + } + + fn selected(app: &SlideApp) -> Vec { + app.session.active().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect() + } + + fn shape_x(app: &SlideApp, id: u64) -> f64 { + let st = app.session.active().unwrap(); + st.shape(deckcraft_model::ShapeId(id as u32)).and_then(|s| s.xfrm).unwrap().x + } + + /// #270: PowerPoint's Normal view draws objects off the slide on the area around it, where + /// they can be selected and moved. + #[test] + fn off_slide_objects_show_on_the_pasteboard_and_can_be_selected_and_moved() { + let mut harness = canvas_harness(); + let off = + harness.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [1000.0, 100.0, 120.0, 80.0]})).unwrap()["id"] + .as_u64() + .unwrap(); + harness.state_mut().session.execute("edit.select", &json!({"ids": []})).unwrap(); + harness.run_steps(3); + + let app = harness.state(); + let (slide, region, k) = (app.slide_rect.unwrap(), app.canvas_region.unwrap(), app.canvas_scale); + // The texture reaches past the slide's right edge to the shape's, and no further left/up. + assert!(region.max.x >= slide.min.x + 1120.0 * k - 1.0, "{region:?} {slide:?}"); + assert!((region.min.x - slide.min.x).abs() < 1.0 && (region.min.y - slide.min.y).abs() < 1.0, "{region:?} {slide:?}"); + let centre = slide.min + egui::vec2(1060.0 * k, 140.0 * k); + assert!(app.canvas_rect.unwrap().contains(centre)); + + // Drawn at full strength on the pasteboard (when a GPU renderer is available here). + if let Ok(img) = harness.render() { + let px = |p: egui::Pos2| img.get_pixel(p.x as u32, p.y as u32).0; + let shape = px(centre); + let board = px(slide.min + egui::vec2(1060.0 * k, 400.0 * k)); + let doc = &harness.state().session.active().unwrap().doc; + let accent = doc.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1); + let close = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 4; + assert!(close(shape[0], accent.r) && close(shape[1], accent.g) && close(shape[2], accent.b), "{shape:?} vs {accent:?}"); + assert_ne!(shape, board); + } + + // Click selects it. + harness.hover_at(centre); + harness.run_steps(1); + harness.drag_at(centre); + harness.run_steps(1); + harness.drop_at(centre); + harness.run_steps(2); + assert_eq!(selected(harness.state()), vec![off]); + + // Drag moves it, further off the slide. + let to = centre + egui::vec2(40.0, 0.0); + harness.hover_at(centre); + harness.run_steps(1); + harness.drag_at(centre); + harness.run_steps(1); + for i in 1..=4 { + harness.hover_at(centre + egui::vec2(10.0 * i as f32, 0.0)); + harness.run_steps(1); + } + harness.drop_at(to); + harness.run_steps(2); + let x = shape_x(harness.state(), off); + assert!((x - (1000.0 + 40.0 / k as f64)).abs() < 1.5, "{x}"); + // The pasteboard followed it. + let (slide, region) = (harness.state().slide_rect.unwrap(), harness.state().canvas_region.unwrap()); + assert!(region.max.x >= slide.min.x + (x as f32 + 120.0) * k - 1.0, "{region:?}"); + + // Idle frames don't render the canvas again. + let n = harness.state().textures.canvas_renders; + harness.run_steps(3); + assert_eq!(harness.state().textures.canvas_renders, n); + } + + /// A slide with nothing off it renders just the slide, as before. + #[test] + fn the_canvas_renders_only_the_slide_when_nothing_is_off_it() { + let mut harness = canvas_harness(); + harness.run_steps(3); + let app = harness.state(); + let (slide, region) = (app.slide_rect.unwrap(), app.canvas_region.unwrap()); + assert!((region.min - slide.min).length() < 0.5 && (region.max - slide.max).length() < 1.0, "{region:?} {slide:?}"); + let n = app.textures.canvas_renders; + harness.run_steps(3); + assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again"); + } } diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 84cf2755..40498422 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -1,11 +1,15 @@ //! Rendered slides as GPU textures: the canvas slide and thumbnails, cached by the slide's and its //! master's `Arc` identity (an unchanged slide keeps its `Arc`, so it never re-renders). +//! +//! The canvas texture covers the slide plus whatever of the slide's drawing reaches off it and is +//! in view (PowerPoint's Normal view shows off-slide objects; thumbnails clip to the slide). use std::collections::HashMap; use std::sync::Arc; +use deckcraft_geom::Rect; use deckcraft_model::{Master, Presentation, ShapeId, Slide}; -use deckcraft_render::{Image, RenderOpts}; +use deckcraft_render::{Image, Region, RenderOpts}; use egui::{ColorImage, TextureHandle, TextureOptions}; #[derive(Clone, PartialEq)] @@ -15,11 +19,15 @@ struct Key { size: (u32, u32), edit: bool, extra: u64, + /// The pasteboard region the slide's drawing calls for (canvas only). + want: Region, } struct Entry { key: Key, tex: TextureHandle, + /// The region the texture covers (at least `key.want`). + region: Region, // Keep the Arcs alive so their addresses can't be reused while cached. _slide: Arc, _master: Option>, @@ -29,6 +37,14 @@ struct Entry { #[derive(Default)] pub struct Textures { canvas: Option, + /// How far the canvas slide's shapes reach (from their boxes), for the slide and master `Arc`s + /// held (kept alive so their addresses can't be reused). + estimate: Option<(Arc, Option>, Rect)>, + /// How far the last canvas render of a slide (by index) actually drew: text spilling out of + /// its box shows there. + drawn: Option<(usize, Rect)>, + /// Canvas renders so far (tests, performance checks). + pub canvas_renders: u64, thumbs: HashMap, clock: u64, pub last_render_ms: f64, @@ -55,41 +71,84 @@ fn master_of(p: &Presentation, slide: &Slide) -> Option> { p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned() } +fn same(a: &Option>, b: &Option>) -> bool { + match (a, b) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, + } +} + fn threads() -> u16 { if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) } } impl Textures { - /// The canvas texture for slide `index` at `size` pixels; `extra` changes with the GIF frames. + /// The canvas texture for slide `index`, the slide being `size` pixels, and the region it + /// covers: the slide, grown over the pasteboard to show what reaches off the slide within + /// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the + /// GIF frames. + #[allow(clippy::too_many_arguments)] pub fn slide( &mut self, ctx: &egui::Context, p: &Presentation, index: usize, size: (u32, u32), + visible: Region, edit: bool, extra: u64, grayscale: bool, gif_times: &[(ShapeId, f64)], - ) -> Option { + ) -> Option<(TextureHandle, Region)> { let slide = p.slides.get(index)?.clone(); let master = master_of(p, &slide); + let scale = size.0 as f64 / p.slide_size.width.max(1.0); + let estimate = match &self.estimate { + Some((s, m, r)) if Arc::ptr_eq(s, &slide) && same(m, &master) => *r, + _ => { + let r = deckcraft_render::slide_extent(p, index); + self.estimate = Some((slide.clone(), master.clone(), r)); + r + } + }; + let reach = match self.drawn { + Some((i, d)) if i == index => estimate.union(d), + _ => estimate, + }; + let want = deckcraft_render::pasteboard_region(size, scale, reach, visible); let key = Key { slide: Arc::as_ptr(&slide) as usize, master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0), size, edit, extra: extra ^ grayscale as u64, + want, }; if let Some(e) = &self.canvas && e.key == key { - return Some(e.tex.clone()); + return Some((e.tex.clone(), e.region)); } let t0 = crate::now_ms(); - let scale = size.0 as f64 / p.slide_size.width.max(1.0); - let opts = RenderOpts { scale, edit, gif_times, threads: threads(), size: Some(size), ..Default::default() }; - let img = deckcraft_render::render_slide(p, index, &opts); + let render = |region: Region| { + let opts = + RenderOpts { scale, edit, gif_times, threads: threads(), offset: region.offset(), size: Some(region.size()), ..Default::default() }; + deckcraft_render::render_slide_drawn(p, index, &opts) + }; + let (mut img, drawn) = render(want); + self.canvas_renders += 1; + let mut region = want; + // Text spilling out of its box can reach further than the boxes said. Once drawn, the + // region it calls for is known (and is next frame's key): render again only if it shows + // more than this render covered. + let key = Key { want: deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), ..key }; + if !region.contains(&key.want) { + region = key.want; + img = render(region).0; + self.canvas_renders += 1; + } + self.drawn = Some((index, drawn)); self.last_render_ms = crate::now_ms() - t0; let ci = to_color_image(&img, grayscale); let tex = match self.canvas.take() { @@ -99,13 +158,15 @@ impl Textures { } None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR), }; - self.canvas = Some(Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: 0 }); - Some(tex) + self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 }); + Some((tex, region)) } /// Force the canvas to re-render next time (fonts loaded, theme changed in place…). pub fn invalidate(&mut self) { self.canvas = None; + self.estimate = None; + self.drawn = None; self.thumbs.clear(); } @@ -121,6 +182,7 @@ impl Textures { size: (width_px, h), edit: false, extra: 0, + want: Region::slide(width_px, h), }; self.clock += 1; let clock = self.clock; @@ -139,7 +201,8 @@ impl Textures { let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() }); let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR); // Drop the old entry for this slide address; entries for slides that no longer exist age out. - self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: clock }); + let region = key.want; + self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock }); if self.thumbs.len() > 600 { let mut v: Vec<(usize, u64)> = self.thumbs.iter().map(|(k, e)| (*k, e.stamp)).collect(); v.sort_by_key(|x| x.1); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5c7c50b9..07ae91c2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; ribbon, menus, panes, dialogs and interaction compared with PowerPoint 16.113 observations in `plan/powerpoint/`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs, direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in @@ -41,6 +41,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa |---|---|---| | Selection handles, rotation handle, yellow adjustment handles | yes | yes | | Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes | +| Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) | | Smart guides (edges, centres, equal spacing) | yes | yes | | Snap to grid, grid options, drawing guides | yes | gridlines, guides and snap to grid (engine setting); no Grid Options dialog or menu toggle | | Nudge (arrows), fine nudge (Option+arrows) | yes | yes | @@ -87,4 +88,5 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 | From 23a3b6eba2e9c24943009adad11e1f52f4987c72 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 22:01:29 -0400 Subject: [PATCH 35/49] Fills: a gradient without stops takes them from its style, and keeps none when saved (#330) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/model/src/resolve.rs | 27 +++++++++++++++++++++------ crates/model/src/tests.rs | 23 +++++++++++++++++++++++ crates/pptx/src/write/dml.rs | 18 +++++++++--------- crates/pptx/tests/fills.rs | 23 +++++++++++++++++++++++ 4 files changed, 76 insertions(+), 15 deletions(-) diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 54eb4c87..acaff2b5 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -134,11 +134,8 @@ pub fn xfrm(ctx: &Ctx, shape: &Shape) -> Xfrm { /// Effective fill and the style colour (`phClr`) to resolve it with. pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option, Option) { let (lp, mp) = parents(ctx, shape); - for s in [Some(shape), lp, mp].into_iter().flatten() { - if let Some(f) = &s.fill { - return (Some(f.clone()), None); - } - if let Some(st) = &s.style { + let style_fill = |s: &Shape| { + s.style.as_ref().map(|st| { let (idx, col) = &st.fill_ref; let ph = ctx.color(col, None); let f = match *idx { @@ -146,7 +143,25 @@ pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option, Option) { i if i >= 1001 => ctx.master.theme.format.bg_fills.get((i - 1001) as usize).cloned(), i => ctx.master.theme.format.fills.get((i - 1) as usize).cloned(), }; - return (f, Some(ph)); + (f, Some(ph)) + }) + }; + for s in [Some(shape), lp, mp].into_iter().flatten() { + if let Some(f) = &s.fill { + // A gradient without stops (`a:gradFill` with no `a:gsLst`) takes them from the + // gradient its style refers to, keeping its own direction, as PowerPoint draws it. + if let Fill::Gradient(g) = f + && g.stops.is_empty() + && let Some((Some(Fill::Gradient(sg)), ph)) = style_fill(s) + { + let mut g = g.clone(); + g.stops = sg.stops; + return (Some(Fill::Gradient(g)), ph); + } + return (Some(f.clone()), None); + } + if let Some(r) = style_fill(s) { + return r; } } if shape.text_box { diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 2d028f86..4423ad1f 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -62,6 +62,29 @@ fn shape_style_fill_resolves_through_theme() { assert!(ph.is_some()); } +#[test] +fn a_gradient_without_stops_takes_them_from_its_style() { + // `a:gradFill` with only a direction: PowerPoint fills it with the stops of the theme gradient + // the style's fillRef names, in the style's colour, along the shape's own direction. + let p = Presentation::default(); + let s = &p.slides[0]; + let ctx = Ctx::for_slide(&p, s).unwrap(); + let own = style::Gradient { stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true }; + let mut st = ShapeStyle::accent(SchemeSlot::Accent2); + st.fill_ref.0 = 3; + let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() }; + let (f, ph) = resolve::fill(&ctx, &sh); + let Some(Fill::Gradient(g)) = f else { panic!("expected a gradient") }; + let Some(Fill::Gradient(theme)) = p.masters[0].theme.format.fills.get(2).cloned() else { panic!("theme fill 3 is a gradient") }; + assert_eq!(g.stops, theme.stops); + assert_eq!(g.shape, own.shape, "the shape's own direction"); + assert_eq!(ph, Some(p.masters[0].theme.colors.get(SchemeSlot::Accent2))); + // A solid style fill has no stops to give: the gradient stays empty (draws nothing). + st.fill_ref.0 = 1; + let sh = Shape { style: Some(st), fill: Some(Fill::Gradient(own.clone())), ..Default::default() }; + assert_eq!(resolve::fill(&ctx, &sh).0, Some(Fill::Gradient(own))); +} + #[test] fn serde_roundtrip() { let p = Presentation::default(); diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 4c2e5d99..6761f0bf 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -78,22 +78,22 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) { Fill::Solid { color: c } => solid(w, c), Fill::Gradient(g) => { w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" })); - w.open0("a:gsLst"); + // No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill. let mut stops = g.stops.clone(); - if stops.is_empty() { - stops.push(deckcraft_model::style::GradientStop { pos: 0.0, color: ColorRef::rgb(deckcraft_color::Rgba::WHITE) }); - } if stops.len() == 1 { let mut s = stops[0].clone(); s.pos = 1.0; stops.push(s); } - for s in &stops { - w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0)))); - color(w, &s.color); - w.close("a:gs"); + if !stops.is_empty() { + w.open0("a:gsLst"); + for s in &stops { + w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0)))); + color(w, &s.color); + w.close("a:gs"); + } + w.close("a:gsLst"); } - w.close("a:gsLst"); match &g.shape { GradientShape::Linear { angle, scaled } => { w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" })) diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs index cd096dea..956af5fd 100644 --- a/crates/pptx/tests/fills.rs +++ b/crates/pptx/tests/fills.rs @@ -79,3 +79,26 @@ fn regex_free_strip(s: &str, open: &str, close: &str) -> String { let Some(j) = s[i..].find(close).map(|j| j + i + close.len()) else { return s.to_string() }; format!("{}{}", &s[..i], &s[j..]) } + +#[test] +fn a_gradient_without_stops_is_saved_without_stops() { + // `a:gradFill` with no `a:gsLst` takes its stops from the style's fill in PowerPoint; saving + // must not turn it into a gradient of its own (it used to become solid white). + use deckcraft_model::style::{Gradient, GradientShape}; + let mut p = defaults::new_presentation(None); + let id = ShapeId(p.alloc_id()); + let first = p.slides[0].id; + let own = Gradient { stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true }; + p.slide_mut(first).expect("slide").shapes.push(Shape { + id, + name: "Styled".into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)), + fill: Some(Fill::Gradient(own.clone())), + style: Some(deckcraft_model::ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), + ..Default::default() + }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape"); + assert_eq!(s.fill, Some(Fill::Gradient(own))); +} From ea3b1159d5a4aa1318aabfa9d0bb7ee9308b82bd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 22:03:42 -0400 Subject: [PATCH 36/49] Korean, Japanese and Chinese: input-method composition and interface text (#101) (#331) * Text: input method composition as commands, one undo step per composition (#101) text.compose shows an input method's preedit in place at the insertion point and replaces it on each update; an empty preedit cancels and restores the text and selection. text.commit replaces the composition with the result as one undo step. Any other edit, key or click settles an open composition first. The composition is underlined under the baseline (thicker under the part being converted), and the caret line is measured for empty placeholders too. Co-Authored-By: Claude Opus 5.5 (1M context) * UI: Korean, Japanese and Chinese input and interface text (#101) The slide turns the input method on while it takes typing, with the candidate window at the caret, and forwards egui Ime events to text.compose / text.commit, so a Korean keyboard composes syllables instead of separate jamo. Interface text in scripts the interface fonts lack (Hangul, Simplified Chinese, Thai...) borrows faces from the font database (installed fonts such as Malgun Gothic; nothing bundled): file names, titles, notes, outline, sections, comments and typed text. Ported and widened from WordCraft's #565/#840 fallback. The notes pane keeps text typed before focus leaves in the same frame (Escape, another slide). Control channel: ui.ime. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ROADMAP.md | 2 + crates/engine/src/cmd/text.rs | 154 +++++++++++++++++++++++ crates/engine/src/keys.rs | 40 ++++++ crates/engine/src/lib.rs | 35 ++++++ crates/engine/src/tests.rs | 168 +++++++++++++++++++++++++ crates/engine/src/tools.rs | 4 + crates/text/src/lib.rs | 27 ++++ crates/text/src/tests.rs | 30 +++++ crates/ui-egui/src/canvas.rs | 52 +++++++- crates/ui-egui/src/control.rs | 17 +++ crates/ui-egui/src/ime_tests.rs | 217 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 59 ++++++++- crates/ui-egui/src/status.rs | 15 ++- crates/ui-egui/src/theme.rs | 31 ++++- crates/ui-egui/src/uifonts.rs | 202 +++++++++++++++++++++++++++++ docs/control-protocol.md | 1 + docs/gaps.md | 9 +- docs/hardware-parity.md | 5 +- docs/localization-parity.md | 12 +- 19 files changed, 1062 insertions(+), 18 deletions(-) create mode 100644 crates/ui-egui/src/ime_tests.rs create mode 100644 crates/ui-egui/src/uifonts.rs diff --git a/ROADMAP.md b/ROADMAP.md index bc987aff..129157fc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,8 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | + +| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 48be3a51..856f7ab0 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -17,6 +17,27 @@ pub fn specs() -> Vec { cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit), cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert), cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete), + // The input method's composition: shown in place, an undo step only once committed. + CommandSpec { + id: "text.compose", + label: "Compose Text", + menu: &[], + shortcut: None, + params: "{text, active?: [start, end]} — an input method's composition (preedit) at the insertion point; empty text cancels it", + enabled: has_text, + run: compose, + journal: false, + undoable: false, + }, + cmd!( + noundo "text.commit", + "Commit Composition", + [], + None, + "{text} — the input method's result replaces its composition, as one undo step", + has_text, + commit + ), cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret), cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select), cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all), @@ -144,6 +165,33 @@ pub fn layout_for(st: &DocState, t: &TextSel) -> Option Option<(deckcraft_geom::Point, deckcraft_geom::Point)> { + if t.notes || t.cell.is_some() { + return None; + } + let sh = st.shape(t.shape)?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let x = xfrm_of(&st.doc, &st.selection, sh); + let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); + let mut body = sh.text.clone().unwrap_or_default(); + if body.paragraphs.is_empty() { + body.paragraphs.push(deckcraft_model::text::Paragraph::default()); + } + let l = deckcraft_text::layout( + &ctx, + sh, + &body, + &deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); + let (cx, top, bot) = l.caret(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 })?; + let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation); + Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot))) +} + fn pos_param(p: &Value, key: &str) -> Option { let a = p.get(key)?.as_array()?; Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)) @@ -340,6 +388,112 @@ pub(crate) fn insert_field(s: &mut Session, field: &str, text: &str) -> Result String { + s.chars().filter(|c| !c.is_control()).collect() +} + +/// Is the composition still where it was put (the paragraph holds all of it)? +fn composition_fits(st: &DocState, c: &crate::Composition) -> bool { + let Some(t) = &st.selection.text else { return false }; + let Some(body) = body_of(st, t) else { return false }; + body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len()) +} + +/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection) +/// or replace the composition with the new preedit. An empty preedit cancels: the presentation +/// goes back to how it was before the composition began. +fn compose(s: &mut Session, p: &Value) -> Result { + let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?); + let n = text.chars().count(); + let active = p.get("active").and_then(Value::as_array).and_then(|a| { + let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize); + (x < y).then_some((x.min(n), y.min(n))) + }); + let current = s.doc()?.composition.clone(); + // A composition whose text was changed under it is kept as it is; a new one starts. + let current = match current { + Some(c) if !composition_fits(s.doc()?, &c) => { + s.end_composition(); + None + } + c => c, + }; + match current { + None if text.is_empty() => Ok(json!({"composing": false})), + None => { + let st = s.doc()?; + let (before, before_selection) = (st.doc.clone(), st.selection.clone()); + let (start, props) = edit_text(s, |body, t| { + let (a, b) = t.ordered(); + let at = ed::delete(body, a, b); + let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) }; + let c = ed::insert(body, at, &text, props.clone()); + t.anchor = c; + t.caret = c; + (at, props) + })?; + s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection }); + Ok(json!({"composing": true, "start": [start.0, start.1], "len": n})) + } + Some(c) if text.is_empty() => { + let st = s.doc_mut()?; + st.doc = c.before; + st.selection = c.before_selection; + st.composition = None; + st.revision += 1; + Ok(json!({"composing": false})) + } + Some(c) => { + let (start, len, props) = (c.start, c.len, c.props.clone()); + edit_text(s, |body, t| { + ed::delete(body, start, (start.0, start.1 + len)); + let e = ed::insert(body, start, &text, props); + t.anchor = e; + t.caret = e; + })?; + s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c }); + Ok(json!({"composing": true, "start": [start.0, start.1], "len": n})) + } + } +} + +/// `text.commit`: the input method's result replaces the composition (or is typed at the +/// insertion point when there is none) and the whole composition becomes one undo step. +fn commit(s: &mut Session, p: &Value) -> Result { + let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?); + if s.doc()?.composition.is_some() || !text.is_empty() { + compose(s, &json!({"text": text}))?; + } + s.end_composition(); + let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default(); + Ok(json!({"caret": [caret.0, caret.1]})) +} + +/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to, +/// thick)` per line, thick under the part being converted. Empty without a composition or when +/// the edited text isn't laid out on the slide (notes, table cells). +pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { + let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] }; + let Some(sh) = st.shape(t.shape) else { return vec![] }; + let Some(l) = layout_for(st, t) else { return vec![] }; + let x = xfrm_of(&st.doc, &st.selection, sh); + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch }; + let mut out = vec![]; + let mut add = |a: usize, b: usize, thick: bool| { + for (x0, x1, y) in l.composition_underlines(pos(a), pos(b)) { + out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y), thick)); + } + }; + add(0, c.len, false); + if let Some((a, b)) = c.active { + add(a, b.min(c.len), true); + } + out +} + fn insert(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string(); insert_text(s, &text, None) diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 966a109b..cbfc705c 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -25,6 +25,7 @@ impl Session { /// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`). /// Returns `{handled: bool, …}`. pub fn key(&mut self, key: &str, mods: Mods) -> Result { + self.end_composition(); let editing = self.active().is_some_and(|d| d.selection.text.is_some()); let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty()); let k = key.to_ascii_lowercase(); @@ -151,6 +152,45 @@ impl Session { self.execute("text.insert", &json!({"text": text})) } + /// An input method's preedit (#101). On a selected shape that isn't being edited, the first + /// one starts editing its text and replaces it, as typing does ([`Session::type_text`]). + /// `active` is the part being converted, in characters of `text`. + pub fn compose_text(&mut self, text: &str, active: Option<(usize, usize)>) -> Result { + if !self.start_typing(text)? { + return Ok(json!({"handled": false})); + } + let mut p = json!({"text": text}); + if let Some((a, b)) = active { + p["active"] = json!([a, b]); + } + self.execute("text.compose", &p) + } + + /// An input method's result (#101): replaces the composition as one undo step. + pub fn commit_text(&mut self, text: &str) -> Result { + if !self.start_typing(text)? { + return Ok(json!({"handled": false})); + } + self.execute("text.commit", &json!({"text": text})) + } + + /// Ready to take typed `text`: editing text already, or one selected shape whose text editing + /// starts with everything selected. False when there is nowhere to type (or nothing to type + /// and nothing being edited). + fn start_typing(&mut self, text: &str) -> Result { + let st = self.doc()?; + if st.selection.text.is_some() { + return Ok(true); + } + let Some(id) = st.selection.shapes.first().copied() else { return Ok(false) }; + if st.selection.shapes.len() != 1 || text.is_empty() { + return Ok(false); + } + self.execute("text.edit", &json!({"id": id}))?; + self.execute("text.selectAll", &json!({}))?; + Ok(true) + } + fn cycle_selection(&mut self, forward: bool) -> Result<()> { let st = self.doc()?; let ids: Vec<_> = st.shapes().iter().filter(|s| !s.hidden).map(|s| s.id).collect(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b1775c2d..f6f15989 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -131,6 +131,24 @@ pub struct Interaction { pub selection: Selection, } +/// Text an input method is composing (#101): its preedit sits in the edited text body, at `start` +/// for `len` characters, until it is committed, replaced or cancelled. The presentation from +/// before it began makes the whole composition one undo step. +#[derive(Clone, Debug)] +pub struct Composition { + /// Where the composed text starts: (paragraph, char). + pub start: (usize, usize), + /// Its length in characters (it never spans paragraphs). + pub len: usize, + /// The part the input method is converting, relative to `start` (drawn with a thicker line). + pub active: Option<(usize, usize)>, + /// The formatting the composed text is typed with (as `text.insert` would choose). + pub props: Option, + /// The presentation and selection before the composition began. + pub before: Arc, + pub before_selection: Selection, +} + #[derive(Clone, Debug)] pub struct DocState { pub doc: Arc, @@ -145,6 +163,8 @@ pub struct DocState { /// Fonts the file's text uses that aren't installed, found when it was opened (empty for new /// documents); the app mentions them once. pub missing_fonts: Vec, + /// An input method's composition in progress ([`Composition`]). + pub composition: Option, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -163,6 +183,7 @@ impl DocState { uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), title, missing_fonts: Vec::new(), + composition: None, } } pub fn is_dirty(&self) -> bool { @@ -403,6 +424,10 @@ impl Session { fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + // Any other edit (or a click, a key, undo) settles an input method's composition first. + if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") { + self.end_composition(); + } if let Err(e) = (spec.enabled)(self) { let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some()); if !named { @@ -453,6 +478,16 @@ impl Session { Ok(r) } + /// Keep an input method's composition as it stands (#101): its text stays and the whole + /// composition becomes one undo step. Nothing happens without one. + pub fn end_composition(&mut self) { + let Some(st) = self.active_mut() else { return }; + let Some(c) = st.composition.take() else { return }; + if !Arc::ptr_eq(&c.before, &st.doc) { + push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection }); + } + } + /// Change only the selection (no undo step). pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> { let st = self.doc_mut()?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2693853b..673f8ec3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -606,3 +606,171 @@ fn shapes_off_the_slide_can_be_selected_and_moved() { s.pointer(ev(PointerKind::Up, 80.0, 420.0)).unwrap(); assert_eq!(s.doc().unwrap().selection.shapes.iter().map(|i| i.0 as u64).collect::>(), vec![half]); } + +fn title_text(s: &Session) -> String { + let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0]; + sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default() +} + +/// #101: Korean typed through an input method arrives as preedit updates (ㅎ → 하 → 한 → 한ㄱ → +/// 한그 → 한글) and a commit. The preedit is shown in place and replaced on each update; the +/// committed text is one undo step, and nothing on the way is an undo step of its own. +#[test] +fn ime_composition_replaces_its_preedit_and_commits_as_one_undo_step() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "A "})).unwrap(); + let undo0 = s.doc().unwrap().history.undo.len(); + for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] { + s.compose_text(pre, None).unwrap(); + assert_eq!(title_text(&s), format!("A {pre}"), "the preedit is shown in place"); + let c = s.doc().unwrap().composition.clone().unwrap(); + assert_eq!((c.start, c.len), ((0, 2), pre.chars().count())); + assert_eq!(s.doc().unwrap().selection.text.as_ref().unwrap().caret, (0, 2 + pre.chars().count()), "caret after the preedit"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo0, "no undo step while composing"); + } + s.commit_text("한글").unwrap(); + assert_eq!(title_text(&s), "A 한글"); + assert!(s.doc().unwrap().composition.is_none()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1, "one undo step for the composition"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Type Text"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(title_text(&s), "A "); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(title_text(&s), "A 한글"); + + // Windows' Korean IME commits a syllable when the next begins: an empty preedit, the commit, + // then a new preedit. + s.compose_text("ㄴ", None).unwrap(); + s.compose_text("", None).unwrap(); + s.commit_text("ㄴ").unwrap(); + s.compose_text("ㅁ", None).unwrap(); + assert_eq!(title_text(&s), "A 한글ㄴㅁ"); + s.commit_text("마").unwrap(); + assert_eq!(title_text(&s), "A 한글ㄴ마"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(title_text(&s), "A 한글ㄴ"); +} + +/// #101: an empty preedit cancels the composition (Escape, or Backspace over its last jamo): +/// the text and the selection it replaced are as they were, with no undo step. +#[test] +fn ime_cancel_restores_the_text_and_selection() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hello world"})).unwrap(); + s.execute("text.select", &json!({"anchor": [0, 6], "caret": [0, 11]})).unwrap(); + let undo0 = s.doc().unwrap().history.undo.len(); + let rev = s.doc().unwrap().revision; + s.compose_text("せかい", Some((0, 3))).unwrap(); + assert_eq!(title_text(&s), "Hello せかい", "the preedit replaces the selection"); + assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 3))); + s.compose_text("世界", Some((0, 9))).unwrap(); + assert_eq!(s.doc().unwrap().composition.as_ref().unwrap().active, Some((0, 2)), "clamped to the preedit"); + s.compose_text("", None).unwrap(); + assert_eq!(title_text(&s), "Hello world"); + let t = s.doc().unwrap().selection.text.clone().unwrap(); + assert_eq!((t.anchor, t.caret), ((0, 6), (0, 11)), "the selection is back"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo0); + assert!(s.doc().unwrap().revision > rev, "redrawn"); + // A commit of nothing without a composition is nothing. + s.commit_text("").unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo0); + // A commit without a preedit (some input methods send only the result) is one step. + s.commit_text("世界").unwrap(); + assert_eq!(title_text(&s), "Hello 世界"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1); +} + +/// #101: anything else settles the composition first: its text stays and is its own undo step. +#[test] +fn ime_composition_is_kept_by_a_key_a_click_or_another_command() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + let undo0 = s.doc().unwrap().history.undo.len(); + s.compose_text("안", None).unwrap(); + s.key("Right", Mods::default()).unwrap(); + assert!(s.doc().unwrap().composition.is_none()); + assert_eq!(title_text(&s), "안"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1); + + s.compose_text("녕", None).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); + assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing"); + s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); + assert!(s.doc().unwrap().composition.is_none(), "a click settles it"); + assert_eq!(title_text(&s), "안녕"); + + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.compose_text("하", None).unwrap(); + s.execute("text.get", &json!({"id": title})).unwrap(); + assert!(s.doc().unwrap().composition.is_some(), "a query doesn't"); + s.execute("text.insert", &json!({"text": "!"})).unwrap(); + assert_eq!(title_text(&s), "안녕하!"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(title_text(&s), "안녕하"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(title_text(&s), "안녕"); + + // Text changed under the composition (here behind its back): a new one starts, no panic. + s.compose_text("ㄱ", None).unwrap(); + s.doc_mut().unwrap().composition.as_mut().unwrap().len = 99; + s.compose_text("가", None).unwrap(); + assert!(title_text(&s).ends_with('가')); + // Control characters (an Enter sent as text) never split the paragraph. + s.commit_text("가\n\r\u{b}").unwrap(); + assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs.len(), 1); +} + +/// #101: the first preedit on a selected shape starts editing it and replaces its text, as typing +/// does; with nothing selected there is nowhere to compose. +#[test] +fn ime_on_a_selected_shape_replaces_its_text() { + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + s.select(|_, sel| { + sel.text = None; + sel.shapes = vec![ShapeId(id as u32)]; + }) + .unwrap(); + assert_eq!(s.compose_text("", None).unwrap()["handled"], false, "an empty preedit doesn't start editing"); + s.compose_text("新", None).unwrap(); + s.commit_text("新しい").unwrap(); + let v = s.execute("text.get", &json!({"id": id})).unwrap(); + assert_eq!(v["text"], "新しい"); + s.select(|_, sel| { + sel.text = None; + sel.shapes.clear(); + }) + .unwrap(); + assert_eq!(s.compose_text("x", None).unwrap()["handled"], false); + assert_eq!(s.commit_text("x").unwrap()["handled"], false); +} + +/// #101: the composition is underlined in place, under the composed characters, with a thicker +/// line under the part being converted. +#[test] +fn ime_composition_underlines_follow_the_text() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty()); + s.compose_text("にほんご", Some((2, 4))).unwrap(); + let u = cmd::text::composition_underlines(s.doc().unwrap()); + let thin: Vec<_> = u.iter().filter(|x| !x.2).collect(); + let thick: Vec<_> = u.iter().filter(|x| x.2).collect(); + assert_eq!((thin.len(), thick.len()), (1, 1), "{u:?}"); + let sh = s.doc().unwrap().current_slide().unwrap().shapes[0].clone(); + let x = cmd::xfrm_of(&s.doc().unwrap().doc, &s.doc().unwrap().selection, &sh); + for (a, b, _) in &u { + assert!(b.x > a.x && (a.y - b.y).abs() < 1e-9, "a horizontal line: {a:?} {b:?}"); + assert!(a.x >= x.x - 1.0 && b.x <= x.x + x.w + 1.0 && a.y > x.y && a.y < x.y + x.h, "inside the title box"); + } + assert!(thick[0].0.x > thin[0].0.x && (thick[0].1.x - thin[0].1.x).abs() < 1e-6, "the converted part is the end"); + s.commit_text("日本語").unwrap(); + assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty()); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 9a33933a..876ea6e9 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -265,6 +265,10 @@ impl Session { if !(p.x.is_finite() && p.y.is_finite()) { return Ok(Value::Null); } + // A press (not a hover) settles an input method's composition, as clicking does in PowerPoint. + if !matches!(ev.kind, PointerKind::Move) { + self.end_composition(); + } match ev.kind { PointerKind::Move => { if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 69e65997..791f257b 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1831,6 +1831,33 @@ impl TextLayout { } out } + /// Underlines for text an input method is composing, from `a` to `b` (#101): `(x0, x1, y)` per + /// visual span, a little below each line's baseline (PowerPoint underlines the composition + /// in place). A composition that wraps gets one underline per line. + pub fn composition_underlines(&self, a: Pos, b: Pos) -> Vec<(f64, f64, f64)> { + let (s, e) = if a <= b { (a, b) } else { (b, a) }; + let mut out = vec![]; + if s == e { + return out; + } + for l in &self.lines { + let lp = Pos { para: l.para, ch: l.start }; + let le = Pos { para: l.para, ch: l.end }; + if le <= s || lp >= e { + continue; + } + let from = if s > lp { s.ch } else { l.start }; + let to = if e < le { e.ch } else { l.end }; + // Under the baseline, in the descent, never below the line. + let y = (l.baseline + (l.bottom - l.baseline) * 0.35).min(l.bottom); + for (x0, x1) in l.spans(from, to) { + if x1 > x0 { + out.push((x0, x1, y)); + } + } + } + out + } /// Vertical caret movement: the position on the line above/below closest to `x`. pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos { let Some(li) = self.line_of(pos) else { return pos }; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 724e63bc..f3dd0132 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -151,6 +151,36 @@ fn caret_and_hit_roundtrip() { assert_eq!(r.len(), 1); } +/// #101: the text an input method is composing is underlined in place, under the baseline of each +/// line it occupies, and only over the composed characters. +#[test] +fn composition_is_underlined_per_line() { + let (p, sh) = setup("Hello 한국어 world", 600.0, 100.0); + let l = lay(&p, &sh, 600.0, 100.0); + let (a, b) = (Pos { para: 0, ch: 6 }, Pos { para: 0, ch: 9 }); + let u = l.composition_underlines(a, b); + assert_eq!(u.len(), 1, "{u:?}"); + let (x0, x1, y) = u[0]; + let (cx0, ..) = l.caret(a).unwrap(); + let (cx1, ..) = l.caret(b).unwrap(); + assert!((x0 - cx0).abs() < 0.01 && (x1 - cx1).abs() < 0.01, "{u:?} vs carets {cx0} {cx1}"); + let line = &l.lines[0]; + assert!(y > line.baseline && y <= line.bottom, "under the baseline: {y} ({} – {})", line.baseline, line.bottom); + // Reversed ends give the same; an empty composition, nothing. + assert_eq!(l.composition_underlines(b, a), u); + assert!(l.composition_underlines(a, a).is_empty()); + + // Wrapped: one underline per line. + let (p, sh) = setup("가나다라마바사아자차카타파하 가나다라마바사아자차카타파하 가나다라마바사", 120.0, 400.0); + let l = lay(&p, &sh, 120.0, 400.0); + assert!(l.lines.len() >= 2); + let u = l.composition_underlines(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 40 }); + assert!(u.len() >= 2, "{u:?}"); + assert!(u.windows(2).all(|w| w[1].2 > w[0].2), "each lower than the last: {u:?}"); + // Positions past the text never panic. + let _ = l.composition_underlines(Pos { para: 3, ch: 0 }, Pos { para: 9, ch: 99 }); +} + #[test] fn alignment_center_and_right() { let (p, mut sh) = setup("Hi", 400.0, 100.0); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 886694f9..644e2f69 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -164,6 +164,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } pointer(app, ui, &resp, xf); overlays(app, ui, &painter, xf, &t); + ime_output(app, ui, xf); media_bar(app, ui, xf); gif_buttons(app, ui, xf, &gifs); context_menu(app, &resp); @@ -185,6 +186,47 @@ fn visible_region(avail: Rect, slide: Rect, pixel: egui::Vec2) -> deckcraft_rend } } +/// Where an input method composes for the slide (#101), on screen: at the insertion point while +/// editing text, or over the one selected shape (typing on it starts editing). `None` when there is +/// nothing to type into. +pub fn ime_area(app: &SlideApp, xf: Xf) -> Option { + let st = app.session.active()?; + if let Some(ts) = &st.selection.text { + if ts.notes { + return None; + } + if let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) { + return Some(Rect::from_two_pos(xf.to_screen(top), xf.to_screen(bot)).expand2(vec2(1.0, 0.0))); + } + let sh = st.shape(ts.shape)?; + return Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh))); + } + if st.selection.shapes.len() != 1 { + return None; + } + let sh = st.shape(*st.selection.shapes.first()?)?; + if sh.is_line() { + return None; + } + Some(screen_rect(xf, &deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh))) +} + +/// Turn the input method on for the slide while it takes typing, with its candidate window at +/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types +/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own. +fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) { + let ctx = ui.ctx(); + if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.home_open() { + return; + } + let Some(r) = ime_area(app, xf) else { return }; + let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default(); + let r = to_global * r; + ctx.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false }); + }); +} + /// Play/pause on an animated GIF under the pointer or selected on its own. fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) { let Some(d) = app.session.active() else { return }; @@ -453,12 +495,16 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q)); painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE)); } + // An input method's composition: underlined in place, thicker under the part being converted. + for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) { + painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret)); + } if !ts.is_range() { let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6; ui.ctx().request_repaint_after(std::time::Duration::from_millis(300)); - if blink && let Some((cx, top, bot)) = l.caret(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }) { - let (p0, p1) = (xf.to_screen(m * Point::new(cx, top)), xf.to_screen(m * Point::new(cx, bot))); - painter.line_segment([p0, p1], Stroke::new(1.5, t.caret)); + // An empty placeholder shows the caret where typing begins too. + if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) { + painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret)); } } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 067610b2..84f511e4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -147,6 +147,23 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> let Some(t) = s("text") else { return err("missing `text`") }; wrap(app.session.type_text(t).map_err(|e| e.to_string())) } + "ui.ime" => { + // An input method's event, through egui like a real one: it reaches the slide's text + // or a focused text field (#101). + let ev = if let Some(t) = s("commit") { + egui::ImeEvent::Commit(t.into()) + } else if let Some(t) = s("preedit") { + let active = p.get("active").and_then(Value::as_array).and_then(|a| { + let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize); + (x <= y).then_some(x..y) + }); + egui::ImeEvent::Preedit { text: t.into(), active_range_chars: active } + } else { + return err("missing `preedit` or `commit`"); + }; + app.synthetic.push(egui::Event::Ime(ev)); + ok(json!({"queued": true})) + } "ui.tool.select" => { let Some(name) = s("tool") else { return err("missing `tool`") }; match tool_kind(name, s("preset")) { diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs new file mode 100644 index 00000000..911a2d1a --- /dev/null +++ b/crates/ui-egui/src/ime_tests.rs @@ -0,0 +1,217 @@ +//! #101: Korean, Japanese and Chinese in the interface: input method composition on the slide and +//! in the notes pane (egui `Ime` events, as egui-winit sends them), and interface text drawn with +//! real glyphs rather than boxes. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use egui::{Event, FontId, ImeEvent}; +use serde_json::json; + +use crate::{Services, SlideApp}; +use deckcraft_engine::Session; + +fn harness(session: Session) -> egui_kittest::Harness<'static, SlideApp> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, Services::default()), + ); + // The app keeps requesting repaints, so step frames instead of run(). + h.run_steps(3); + h +} + +fn preedit(text: &str) -> Event { + Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: None }) +} + +fn title_text(app: &SlideApp) -> String { + let sh = &app.session.active().unwrap().doc.slides[0].shapes[0]; + sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default() +} + +fn undo_len(app: &SlideApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +/// Typing 한글 with the Korean input method in the title: each preedit replaces the last in the +/// text box (not ㅎ ㅏ ㄴ …), is underlined, and the commit is one undo step. The input method +/// is on while the slide's text is edited, with its window at the insertion point. +#[test] +fn korean_composes_in_a_slide_text_box() { + let mut h = harness(Session::with_new()); + assert!(h.output().platform_output.ime.is_none(), "nothing to type into: the input method stays off"); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.run_steps(2); + let ime = h.output().platform_output.ime.expect("the input method is on while editing"); + let slide = h.state().slide_rect.unwrap(); + assert!(slide.contains(ime.rect.center()), "candidate window at the insertion point {:?}, on the slide {slide:?}", ime.rect); + assert!(ime.rect.width() < 8.0 && ime.rect.height() > 4.0, "a caret, not the box: {:?}", ime.rect); + let caret0 = ime.rect; + + let undo0 = undo_len(h.state()); + for pre in ["ㅎ", "하", "한", "한ㄱ", "한그", "한글"] { + h.event(preedit(pre)); + h.run_steps(1); + assert_eq!(title_text(h.state()), pre, "the preedit is shown in place, replacing the last"); + assert_eq!(undo_len(h.state()), undo0, "no undo step while composing"); + let st = h.state().session.active().unwrap(); + assert!(!deckcraft_engine::cmd::text::composition_underlines(st).is_empty(), "underlined while composing"); + } + h.run_steps(1); + let ime = h.output().platform_output.ime.unwrap(); + assert!(ime.rect.center().x > caret0.center().x, "the candidate window follows the insertion point"); + h.event(Event::Ime(ImeEvent::Commit("한글".into()))); + h.run_steps(2); + assert_eq!(title_text(h.state()), "한글"); + assert!(h.state().session.active().unwrap().composition.is_none()); + assert!(deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap()).is_empty()); + assert_eq!(undo_len(h.state()), undo0 + 1, "the composition is one undo step"); + + // Escape cancels a composition: an empty preedit, and the text is as it was. + h.event(preedit("ㅁ")); + h.event(preedit("")); + h.run_steps(2); + assert_eq!(title_text(h.state()), "한글"); + assert_eq!(undo_len(h.state()), undo0 + 1); + + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(1); + assert_eq!(title_text(h.state()), "", "one undo removes the whole word"); +} + +/// Japanese conversion: the part being converted is drawn with a thicker line; a key settles a +/// composition the input method left open. +#[test] +fn japanese_conversion_shows_the_active_part() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.event(Event::Ime(ImeEvent::Preedit { text: "日本ご".into(), active_range_chars: Some(2..3) })); + h.run_steps(2); + let u = deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap()); + assert_eq!(u.iter().filter(|x| x.2).count(), 1, "{u:?}"); + let undo0 = undo_len(h.state()); + h.key_press(egui::Key::End); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日本ご"); + assert!(h.state().session.active().unwrap().composition.is_none()); + assert_eq!(undo_len(h.state()), undo0 + 1); +} + +/// Typing on a selected (not edited) shape with an input method replaces its text, as typing does; +/// the input method is on for it. +#[test] +fn composing_on_a_selected_shape_starts_editing_it() { + let mut h = harness(Session::with_new()); + let r = h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + h.state_mut().run("text.exit", json!({})).unwrap(); + h.run_steps(2); + assert!(h.state().session.active().unwrap().selection.text.is_none()); + assert!(h.output().platform_output.ime.is_some(), "a selected shape takes typing"); + h.event(preedit("新")); + h.event(Event::Ime(ImeEvent::Commit("新しい".into()))); + h.run_steps(2); + let v = h.state_mut().session.execute("text.get", &json!({"id": id})).unwrap(); + assert_eq!(v["text"], "新しい"); +} + +/// The notes pane is an egui text field: composition shows in it and replaces each preedit, the +/// candidate window is at its caret, and the notes are one undo step when it loses focus. +#[test] +fn korean_composes_in_the_notes_pane() { + let mut h = harness(Session::with_new()); + let id = egui::Id::new("notes_edit"); + h.ctx.memory_mut(|m| m.request_focus(id)); + h.run_steps(2); + assert!(h.ctx.memory(|m| m.has_focus(id))); + let undo0 = undo_len(h.state()); + for pre in ["ㄴ", "노", "놑", "노트"] { + h.event(preedit(pre)); + h.run_steps(1); + assert_eq!(h.state().notes_buf.2, pre, "the preedit replaces the last one"); + } + let ime = h.output().platform_output.ime.expect("the notes field turns the input method on"); + let slide = h.state().slide_rect.unwrap(); + assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide"); + h.event(Event::Ime(ImeEvent::Commit("노트".into()))); + h.run_steps(1); + assert_eq!(h.state().notes_buf.2, "노트"); + // Focus leaves the field (Escape): the notes are committed as one step. + h.key_press(egui::Key::Escape); + h.run_steps(2); + let notes = h.state().session.active().unwrap().doc.slides[0].notes_text(); + assert_eq!(notes, "노트"); + assert_eq!(undo_len(h.state()), undo0 + 1); + assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), "노트")) || !system_has("노트")); + + // Composing, then going to another slide: the notes stay with the slide they were typed for. + h.state_mut().run("slide.new", json!({})).unwrap(); + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.run_steps(2); + h.ctx.memory_mut(|m| m.request_focus(id)); + h.run_steps(2); + h.event(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.event(preedit("ㅇ")); + h.event(Event::Ime(ImeEvent::Commit("요".into()))); + h.run_steps(1); + h.state_mut().run("slide.go", json!({"index": 1})).unwrap(); + h.run_steps(2); + let d = h.state().session.active().unwrap(); + assert_eq!(d.doc.slides[0].notes_text(), "노트요"); + assert_eq!(d.doc.slides[1].notes_text(), ""); +} + +/// Does some font on this machine (embedded or installed) draw every letter of `s`? Machines +/// without a Korean or Chinese font (a bare Linux runner) can't draw it in the interface either. +fn system_has(s: &str) -> bool { + s.chars().filter(|c| !c.is_whitespace()).all(|c| deckcraft_fonts::FontDb::global().fallback_for(c, u32::MAX).is_some_and(|f| !f.is_color())) +} + +/// #101: a Korean file name, title, notes and comments, a Japanese slide and a Chinese section are +/// drawn with glyphs in the interface (title bar, outline, notes pane, comments), not boxes: faces +/// that draw them are borrowed from the font database. A Latin presentation borrows nothing. +#[test] +fn cjk_text_in_the_interface_is_drawn_with_real_glyphs() { + let h = harness(Session::with_new()); + assert!(h.state().ui_fonts.faces.is_empty(), "nothing to borrow for {:?}", h.state().session.active().unwrap().title()); + + let mut s = Session::with_new(); + let korean = "한국어 발표 자료"; + let japanese = "日本語のスライド"; + let chinese = "简体中文演示文稿"; + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.set", &json!({"id": title, "text": japanese})).unwrap(); + s.execute("slide.notes", &json!({"text": "발표자 노트입니다"})).unwrap(); + s.execute("section.add", &json!({"name": chinese})).unwrap(); + s.doc_mut().unwrap().path = Some(format!("{korean}.pptx")); + let mut h = harness(s); + h.state_mut().run("view.outline", json!({})).unwrap(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().title().contains(korean)); + let mut checked = 0; + for text in [korean, japanese, chinese, "발표자 노트입니다"] { + if !system_has(text) { + eprintln!("skipped {text:?}: no font on this machine draws it"); + continue; + } + for family in [FontId::proportional(13.0), crate::theme::bold(13.0)] { + let missing: String = text.chars().filter(|c| !c.is_whitespace() && !h.ctx.fonts_mut(|f| f.has_glyph(&family, *c))).collect(); + assert!(missing.is_empty(), "{family:?} draws {missing:?} of {text:?} as boxes"); + } + checked += 1; + } + if cfg!(windows) || cfg!(target_os = "macos") { + assert_eq!(checked, 4, "Windows and macOS have Korean and Chinese fonts"); + } + // Text typed later (a dialog field, a new comment) is looked at too. + let typed = "ไทย"; + h.event(Event::Text(typed.into())); + h.run_steps(2); + if system_has(typed) { + assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), typed))); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0bd854bc..1d786b21 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -23,6 +23,7 @@ pub mod textures; pub mod theme; pub mod thumbs; pub mod toast; +pub mod uifonts; pub mod widgets; use std::sync::mpsc::{Receiver, Sender}; @@ -214,6 +215,8 @@ pub struct SlideApp { /// Quit was confirmed (changes saved or discarded): let the window close. pub quit_confirmed: bool, pub(crate) notes_buf: (u64, usize, String), + /// Faces borrowed from the font database for text the interface fonts can't draw (#101). + pub ui_fonts: uifonts::UiFonts, /// Thumbnail pane drag (slide index, current drop index). pub(crate) thumb_drag: Option<(usize, usize)>, } @@ -253,6 +256,7 @@ impl SlideApp { last_recovery: 0.0, quit_confirmed: false, notes_buf: (0, usize::MAX, String::new()), + ui_fonts: uifonts::UiFonts::default(), thumb_drag: None, } } @@ -545,7 +549,7 @@ impl SlideApp { ctx.set_zoom_factor(scale); } if !self.styled { - theme::install_fonts(ctx); + theme::install_fonts_with(ctx, &self.ui_fonts.faces); self.styled = true; self.restyle = true; } else { @@ -568,6 +572,9 @@ impl SlideApp { if !self.synthetic.is_empty() { ctx.request_repaint(); } + if self.fonts_ready { + self.note_ui_text(ctx); + } self.collect_screenshots(ctx); self.issue_screenshots(ctx); // Closing the window with unsaved changes asks first. @@ -599,6 +606,52 @@ impl SlideApp { } } + /// Borrow faces for interface text in scripts the interface fonts lack (#101): the open + /// presentations' names and text, recent files, and what is typed or composed this frame. + fn note_ui_text(&mut self, ctx: &egui::Context) { + let typed: String = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()), + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()), + _ => None, + }) + .collect() + }); + self.ui_fonts.note(ctx, &typed); + for d in self.session.documents() { + self.ui_fonts.note(ctx, &d.title()); + } + for r in &self.ui.recent { + self.ui_fonts.note(ctx, r); + } + if let Some(st) = self.session.active() { + self.ui_fonts.note_doc(ctx, st); + } + self.ui_fonts.apply(ctx); + } + + /// An input method's event for the slide's text (#101): the preedit is shown in the text and + /// replaced on each update; the commit replaces it as one undo step. + fn ime_event(&mut self, e: &egui::ImeEvent) { + let r = match e { + egui::ImeEvent::Preedit { text, active_range_chars } => { + self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end))) + } + egui::ImeEvent::Commit(text) => self.session.commit_text(text), + #[allow(deprecated)] + egui::ImeEvent::Disabled => { + self.session.end_composition(); + Ok(Value::Null) + } + _ => Ok(Value::Null), + }; + if let Err(e) = r { + self.set_status(e.to_string()); + } + } + /// Inject synthetic events (one pointer event per frame). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { if self.synthetic.is_empty() { @@ -646,6 +699,7 @@ impl SlideApp { self.set_status(e.to_string()); } } + egui::Event::Ime(ime) => self.ime_event(&ime), egui::Event::Copy => { if let Ok(v) = self.session.execute("edit.copy", &json!({})) && let Some(t) = v.get("text").and_then(Value::as_str) @@ -889,6 +943,9 @@ fn js_now() -> f64 { js_sys::Date::now() } +#[cfg(test)] +mod ime_tests; + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index a6f4eb11..8e915b71 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -129,12 +129,19 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { ui.painter() .line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator)); // Keep a local buffer per slide; commit when it changes and the field loses focus. - if app.notes_buf.0 != uid || app.notes_buf.1 != idx { - app.notes_buf = (uid, idx, current.clone()); - } let id = egui::Id::new("notes_edit"); let focused = ui.ctx().memory(|m| m.has_focus(id)); - if !focused && app.notes_buf.2 != current { + // Focus can leave in the frame before the field is drawn (Escape, a click on a + // thumbnail): what was typed is still committed, to the slide it was typed for. + let was_focused = ui.ctx().memory(|m| m.had_focus_last_frame(id)); + if app.notes_buf.0 != uid || app.notes_buf.1 != idx { + let (old_uid, old_idx, typed) = std::mem::replace(&mut app.notes_buf, (uid, idx, current.clone())); + let old = app.session.active().and_then(|d| d.doc.slides.get(old_idx)).map(|s| s.notes_text()); + if was_focused && old_uid == uid && old.is_some_and(|o| o != typed) { + let _ = app.run("slide.notes", json!({"text": typed, "index": old_idx})); + } + } + if !focused && !was_focused && app.notes_buf.2 != current { app.notes_buf.2 = current.clone(); } let mut buf = app.notes_buf.2.clone(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 00167145..33cbfbe8 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -155,6 +155,18 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) { /// UI fonts: Inter (from craft-fonts) when embedded, a Japanese UI face for CJK, then egui's. pub fn install_fonts(ctx: &egui::Context) { + install_fonts_with(ctx, &[]); +} + +/// [`install_fonts`], with `extra` faces from the font database after every interface font: the +/// installed or embedded faces that draw text the interface faces lack (Hangul, Simplified +/// Chinese, Devanagari…, #101). See [`crate::uifonts`]. +pub fn install_fonts_with(ctx: &egui::Context, extra: &[std::sync::Arc]) { + ctx.set_fonts(font_definitions(extra)); +} + +/// The interface font definitions; see [`install_fonts_with`]. +pub fn font_definitions(extra: &[std::sync::Arc]) -> FontDefinitions { let mut defs = FontDefinitions::default(); let mut names = vec![]; for (i, f) in deckcraft_fonts::CRAFT_FONTS.iter().enumerate() { @@ -178,7 +190,24 @@ pub fn install_fonts(ctx: &egui::Context) { let mut bold: Vec = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect(); bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default()); defs.families.insert(FontFamily::Name("bold".into()), bold); - ctx.set_fonts(defs); + add_fallback_faces(&mut defs, extra); + defs +} + +/// Add `faces` after every other font of each interface family (the last fallbacks). +pub fn add_fallback_faces(defs: &mut FontDefinitions, faces: &[std::sync::Arc]) { + for f in faces { + let name = format!("fallback {} {} {}", f.family, f.style, f.id()); + if defs.font_data.contains_key(&name) { + continue; + } + let mut data = FontData::from_owned(f.data().to_vec()); + data.index = f.index(); + defs.font_data.insert(name.clone(), std::sync::Arc::new(data)); + for list in defs.families.values_mut() { + list.push(name.clone()); + } + } } pub fn bold(size: f32) -> egui::FontId { diff --git a/crates/ui-egui/src/uifonts.rs b/crates/ui-egui/src/uifonts.rs new file mode 100644 index 00000000..50ebc87b --- /dev/null +++ b/crates/ui-egui/src/uifonts.rs @@ -0,0 +1,202 @@ +//! Interface fonts for text in any script (#101). The interface faces (Inter, BIZ UDPGothic, +//! Noto Sans Arabic and egui's own) leave Hangul, Simplified Chinese, Thai, Devanagari… without +//! glyphs, so a Korean file name, title, speaker note or comment would be drawn as boxes. For each +//! character on screen that they lack, a face that has it is borrowed from the font database (the +//! craft-fonts faces, then installed fonts such as Malgun Gothic, Apple SD Gothic Neo or Noto +//! Sans CJK) and added after the interface faces. Nothing is bundled for it: on the web, where +//! there are no installed fonts, only the embedded faces are used. +//! +//! Ported from WordCraft's IME composition and window-caption fallback (#565, #840), widened from +//! the composition and caption to every text the interface draws from the presentation. + +use std::collections::HashSet; +use std::sync::Arc; + +use deckcraft_fonts::{FontDb, FontFace}; + +/// Faces larger than this are left out (egui keeps a copy of each face it is given). +const MAX_FACE_BYTES: usize = 48 << 20; + +/// Faces borrowed for the interface, and the characters already looked at. +#[derive(Default)] +pub struct UiFonts { + /// Faces added after the interface fonts, in the order they were found. + pub faces: Vec>, + /// Characters already looked at (drawn, given a face, or found in no face). + seen: HashSet, + /// The presentation last looked through: (uid, revision). + doc: Option<(u64, u64)>, + /// Faces were added since the fonts were last installed. + changed: bool, +} + +/// Characters worth a lookup: not ASCII, a space, a control or a private-use character. +fn wanted(c: char) -> bool { + !(c.is_ascii() || c.is_whitespace() || c.is_control() || matches!(c, '\u{E000}'..='\u{F8FF}' | '\u{F0000}'..)) +} + +/// The faces needed to draw `text`, beyond `have`: one for each character that `covered` says the +/// interface fonts can't draw, found with `lookup`, skipping characters already `seen`. Colour and +/// very large faces are skipped (egui draws neither well). +pub fn faces_for_text( + text: &str, + have: &[Arc], + seen: &mut HashSet, + mut covered: impl FnMut(char) -> bool, + lookup: impl Fn(char) -> Option>, +) -> Vec> { + let mut out: Vec> = Vec::new(); + for c in text.chars().filter(|c| wanted(*c)) { + if !seen.insert(c) || covered(c) || have.iter().chain(&out).any(|f| f.covers(c)) { + continue; + } + let Some(f) = lookup(c) else { continue }; + if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) { + continue; + } + out.push(f); + } + out +} + +impl UiFonts { + /// Look at `text` the interface is about to draw; borrow faces for what it can't draw. + pub fn note(&mut self, ctx: &egui::Context, text: &str) { + if !text.chars().any(|c| wanted(c) && !self.seen.contains(&c)) { + return; + } + let font = egui::FontId::proportional(13.0); + let new = ctx.fonts_mut(|f| { + faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX)) + }); + if !new.is_empty() { + log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::>().join(", ")); + self.faces.extend(new); + self.changed = true; + } + } + + /// Look through the text of a presentation the interface shows (once per revision): its + /// title, slide text and names, notes, comments, sections and layout names. + pub fn note_doc(&mut self, ctx: &egui::Context, st: &deckcraft_engine::DocState) { + if self.doc == Some((st.uid, st.revision)) { + return; + } + self.doc = Some((st.uid, st.revision)); + let text = ui_text(st); + self.note(ctx, &text); + } + + /// Install the interface fonts again when faces were added. True if they were. + pub fn apply(&mut self, ctx: &egui::Context) -> bool { + if !std::mem::take(&mut self.changed) { + return false; + } + crate::theme::install_fonts_with(ctx, &self.faces); + ctx.request_repaint(); + true + } +} + +/// The text of a presentation that the interface can show (not the slides' pictures of it). +pub fn ui_text(st: &deckcraft_engine::DocState) -> String { + let mut out = st.title(); + let mut add = |s: &str| { + if s.chars().any(wanted) { + out.push('\n'); + out.push_str(s); + } + }; + let body = |b: &deckcraft_model::text::TextBody, add: &mut dyn FnMut(&str)| { + for p in &b.paragraphs { + for r in &p.runs { + add(&r.text); + } + } + }; + let doc = &st.doc; + add(&doc.props.title); + for sec in &doc.sections { + add(&sec.name); + } + let shapes = |list: &[deckcraft_model::Shape], add: &mut dyn FnMut(&str)| { + deckcraft_model::walk(list, &mut |sh, _| { + add(&sh.name); + add(&sh.title); + add(&sh.descr); + if let Some(t) = &sh.text { + body(t, add); + } + if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind { + for row in &tb.rows { + for cell in &row.cells { + body(&cell.text, add); + } + } + } + }); + }; + for s in &doc.slides { + add(&s.name); + shapes(&s.shapes, &mut add); + body(&s.notes, &mut add); + let mut stack: Vec<&deckcraft_model::Comment> = s.comments.iter().collect(); + while let Some(c) = stack.pop() { + add(&c.author); + add(&c.text); + stack.extend(c.replies.iter()); + } + } + for m in &doc.masters { + add(&m.name); + for l in &m.layouts { + add(&l.name); + } + } + out +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + fn face(family: &str) -> Arc { + FontDb::global().face(family, "Regular") + } + + /// One face for a whole string, looked up once per character, never for characters the + /// interface already draws, and never twice. + #[test] + fn one_face_per_script_and_each_character_once() { + let inter = face("Inter"); + let looked = std::cell::Cell::new(0); + let lookup = |_: char| { + looked.set(looked.get() + 1); + Some(inter.clone()) + }; + let mut seen = HashSet::new(); + let got = faces_for_text("한국어 발표", &[], &mut seen, |_| false, lookup); + assert_eq!(got.len(), 1, "one face, not one per character"); + // Inter doesn't cover Hangul, so every character was looked up once. + assert_eq!(looked.get(), 5); + assert!(faces_for_text("한국어 발표", &got, &mut seen, |_| false, lookup).is_empty(), "already seen"); + assert_eq!(looked.get(), 5); + let mut seen = HashSet::new(); + assert!(faces_for_text("한국어", &[], &mut seen, |_| true, lookup).is_empty(), "the interface draws it"); + assert!(faces_for_text("abc \n\t\u{E001}", &[], &mut HashSet::new(), |_| false, lookup).is_empty(), "ASCII, spaces, private use"); + assert!(faces_for_text("한", std::slice::from_ref(&inter), &mut HashSet::new(), |_| false, lookup).is_empty(), "already borrowed"); + } + + #[test] + fn interface_text_of_a_presentation() { + let mut s = deckcraft_engine::Session::with_new(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.set", &serde_json::json!({"id": title, "text": "한국어 제목"})).unwrap(); + s.execute("slide.notes", &serde_json::json!({"text": "発表者ノート"})).unwrap(); + let t = ui_text(s.doc().unwrap()); + assert!(t.contains("한국어 제목") && t.contains("発表者ノート"), "{t}"); + assert!(t.starts_with(&s.doc().unwrap().title())); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 449b7fc6..46c77aec 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -19,6 +19,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` | | `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) | +| `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels | | `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane | | `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) | | `ui.dialog.open` / `ui.dialog.close` | `{id, fields?}` | Open or close a dialog by id; `fields` pre-fills its fields (About: `{"tab": "contributors"}` or `"models"`) | diff --git a/docs/gaps.md b/docs/gaps.md index 4ab640aa..a433df73 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -225,7 +225,11 @@ Detail in [localization-parity.md](localization-parity.md). - **English only, no string catalog** (#15, #30; Ukrainian PR #34 open). PowerPoint for Mac ships 26 languages. Infrastructure 8–14 h, then 3–16 h per language plus native review. Beta needs the infrastructure and at least Simplified Chinese, Spanish, French, German and Japanese (~30–45 h). -- **No IME composition in text editing** (CJK input). 3–6 h. +- **Hangul in the interface needs an installed Korean font** (#101). craft-fonts has no Korean face + (Noto CJK is excluded by the asset policy), so the web build draws Korean file names, notes and + outline text as boxes; desktop borrows Malgun Gothic, Apple SD Gothic Neo or Noto Sans CJK. + Adding an openly licensed Korean face (e.g. Nanum Gothic, OFL) to craft-fonts: 1–2 h. +- **Table cells don't underline an IME composition** (the text is composed in place, #101). 1 h. - **UI isn't mirrored for right-to-left languages.** 6–10 h. - **Browser build draws Arabic and Hebrew as missing-glyph boxes in exports** (#81). 1–2 h. @@ -251,6 +255,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles | | 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) | | 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 2a189242..5ba34cfc 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -46,7 +46,7 @@ because it mostly follows from hardware use. | Pinch to zoom, two-finger gestures | yes | no | | Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width | | Touch screens (Windows) | yes | basic pointer only | -| IME input methods for CJK text | yes | no (see [localization-parity.md](localization-parity.md)) | +| IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) | | Dictation | yes (macOS) | no | ## Performance @@ -71,4 +71,5 @@ outlines every frame (#67, fix open), and the decoders run in software. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | IME input methods (#101) | | 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 24e308a1..3437cf53 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text, #101; previous: major, created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft's interface is English only today: strings are literals in the UI and engine code, with no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34 @@ -25,16 +25,16 @@ support partly there), **55–90 h** to parity plus native-speaker review. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support (slide text) | Native review | Status | PowerPoint for Mac UI | Hours to full | |---|---|---|---|---|---|---|---|---| | English | en | ~1,050 / ~1,050 (100%) | yes / yes / no help | full | yes | **full** | yes | 0 | -| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; **no IME composition**; no true vertical layout | no | none | yes | 6–10 (incl. IME) | +| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; IME composition (#101); no true vertical layout | no | none | yes | 6–10 (incl. IME) | | Spanish | es | 0 (0%) | no | full (Latin) | no | none | yes (es, es-MX) | 3–5 | | Hindi | hi | 0 (0%) | no | Devanagari shaping through HarfRust, untested; no bundled Devanagari font | no | none | **no** (Windows only) | 4–7 | | Arabic | ar | 0 (0%) | no | bidi, contextual shaping, kashida justification, RTL bullets and caret (#4); **UI not mirrored**; browser export shows boxes (#81) | no | none | yes | 10–16 (incl. UI mirroring) | | French | fr | 0 (0%) | no | full (Latin) | no | none | yes (fr, fr-CA) | 3–5 | | Portuguese | pt | 0 (0%) | no | full (Latin) | no | none | yes (pt-BR, pt-PT) | 3–5 | | Indonesian | id | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 | -| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; no IME composition; vertical text is rotated, not laid out vertically | no | none | yes | 4–7 | +| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; IME composition (#101); vertical text is rotated, not laid out vertically | no | none | yes | 4–7 | | German | de | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 | -| Korean | ko | 0 (0%) | no | system Hangul fonts; no IME composition | no | none | yes | 4–7 | +| Korean | ko | 0 (0%) | no | system Hangul fonts, in the interface too; IME composition (#101) | no | none | yes | 4–7 | | Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 | Other languages shipped: **0** (Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more @@ -44,7 +44,8 @@ Polish, Russian, Slovak, Swedish, Thai, Turkish and Traditional Chinese. Per-language hours assume the infrastructure exists: **8–14 h** once for a string catalog (copy a sibling app's approach, e.g. EffectCraft's menu and panel catalogs), a language setting that follows the system (#15), plural and number formatting, and an `xtask` check that every key has a -translation. IME composition (3–6 h) is counted once, under Simplified Chinese. +translation. IME composition landed with #101 (slide text, notes and text fields; one undo step +per composition); the hours that included it are kept until the next re-measure. ## Proofing languages @@ -56,4 +57,5 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) | | 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle | From 9d571ef644f1678fcf72969f34abfb1dbfff7ee5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 22:05:44 -0400 Subject: [PATCH 37/49] Selection Pane: reorder by drag and drop, groups, Show/Hide All and Lock, as in PowerPoint (#133) (#332) * #133: Selection pane engine: restack inside groups, Show/Hide All, Lock like PowerPoint - arrange.bringForward/sendBackward/bringToFront/sendToBack and arrange.reorder work on a group's children, which stay in their group (as PowerPoint's pane arrows and drag do); steps that change nothing leave no undo step. - selection.select (pane click: Ctrl toggles, children and top-level objects don't mix, picking a hidden object shows it), selection.showAll (everything, children too) and selection.hideAll (top level). - shape.lock cascades through groups; locked objects can be selected, formatted and typed in, but not moved, sized, rotated, flipped, reshaped or cropped (commands and canvas). - PPTX: Lock reads and writes PowerPoint's full locking attribute set per element kind (observed by saving locked objects from PowerPoint); partial locks aren't Lock. Co-Authored-By: Claude Opus 5.5 (1M context) * Front-most first with groups folding; drag a row to restack among its siblings (accent insertion line); Bring Forward / Send Backward arrows; Show All / Hide All; per-row eye and padlock; double-click or F2 renames in place; Ctrl+click toggles, Shift+click selects a run; arrows, Home, End and Esc in the list; right-click menu. Lock/Unlock on the canvas menu, greyed handles on locked objects. egui_kittest tests drive the pane. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ROADMAP.md | 2 + crates/engine/src/cmd/arrange.rs | 128 +++++-- crates/engine/src/cmd/mod.rs | 14 + crates/engine/src/cmd/selection.rs | 126 +++++++ crates/engine/src/cmd/selection_tests.rs | 264 +++++++++++++++ crates/engine/src/cmd/shape.rs | 44 ++- crates/engine/src/tools.rs | 16 +- crates/model/src/lib.rs | 40 +++ crates/model/src/tests.rs | 20 ++ crates/pptx/src/read/shapes.rs | 16 +- crates/pptx/src/write/shapes.rs | 20 +- crates/pptx/tests/locks.rs | 211 ++++++++++++ crates/ui-egui/src/canvas.rs | 12 + crates/ui-egui/src/icons.rs | 24 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/panes.rs | 68 +--- crates/ui-egui/src/selpane.rs | 412 +++++++++++++++++++++++ crates/ui-egui/src/selpane_tests.rs | 255 ++++++++++++++ docs/ui-parity.md | 4 +- 19 files changed, 1550 insertions(+), 130 deletions(-) create mode 100644 crates/engine/src/cmd/selection.rs create mode 100644 crates/engine/src/cmd/selection_tests.rs create mode 100644 crates/pptx/tests/locks.rs create mode 100644 crates/ui-egui/src/selpane.rs create mode 100644 crates/ui-egui/src/selpane_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 129157fc..0f1e02fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,8 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | + +| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | | 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index c755050e..0c91bbaa 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -13,7 +13,15 @@ pub fn specs() -> Vec { cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)), cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)), cmd!("arrange.sendToBack", "Send to Back", ["Arrange"], Some("Cmd+Shift+["), "{ids?}", has_selection, |s, p| order(s, p, -2)), - cmd!("arrange.reorder", "Reorder Objects", ["Arrange"], None, "{id, index: position in the stack (0 = back)}", has_slide, reorder), + cmd!( + "arrange.reorder", + "Reorder Objects", + ["Arrange"], + None, + "{id, index: position among its siblings (0 = back; a group's child stays in its group)} → {index}", + has_slide, + reorder + ), cmd!("arrange.group", "Group", ["Arrange"], Some("Cmd+Alt+G"), "{ids?}", has_two, group), cmd!("arrange.ungroup", "Ungroup", ["Arrange"], Some("Cmd+Alt+Shift+G"), "{ids?}", has_selection, ungroup), cmd!("arrange.regroup", "Regroup", ["Arrange"], Some("Cmd+Alt+J"), "{}", has_doc, regroup), @@ -50,52 +58,98 @@ pub fn specs() -> Vec { fn order(s: &mut Session, p: &Value, how: i32) -> Result { let ids = targets(s, p)?; + // Already in place (front of its group, say): no change and no undo step. + let mut trial = s.doc()?.shapes().to_vec(); + order_tree(&mut trial, &ids, how, 0); + if stacking(&trial) == stacking(s.doc()?.shapes()) { + return ok(); + } s.edit(|doc, sel| { let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange", "no slide"))?; - match how { - 2 => { - let (mut moved, rest): (Vec, Vec) = list.drain(..).partition(|x| ids.contains(&x.id)); - list.extend(rest); - list.append(&mut moved); - } - -2 => { - let (mut moved, rest): (Vec, Vec) = list.drain(..).partition(|x| ids.contains(&x.id)); - moved.extend(rest); - *list = moved; - } - 1 => { - for i in (0..list.len().saturating_sub(1)).rev() { - let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id))); - if a == Some(true) && b == Some(false) { - list.swap(i, i + 1); - } - } - } - _ => { - for i in 1..list.len() { - let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id))); - if a == Some(false) && b == Some(true) { - list.swap(i - 1, i); - } - } - } - } + order_tree(list, &ids, how, 0); Ok(()) })?; ok() } +/// Every shape id with its depth, in tree order. +fn stacking(v: &[Shape]) -> Vec<(ShapeId, usize)> { + let mut out = vec![]; + deckcraft_model::walk(v, &mut |sh, d| out.push((sh.id, d))); + out +} + +/// Restack `ids` among their siblings, in every list of the tree: a shape inside a group moves +/// within its group and never leaves it (PowerPoint's Selection pane arrows work the same way). +fn order_tree(list: &mut Vec, ids: &[ShapeId], how: i32, depth: usize) { + if depth > 64 { + return; + } + if list.iter().any(|x| ids.contains(&x.id)) { + order_list(list, ids, how); + } + for sh in list.iter_mut() { + if let Some(children) = sh.children_mut() { + order_tree(children, ids, how, depth + 1); + } + } +} + +fn order_list(list: &mut Vec, ids: &[ShapeId], how: i32) { + match how { + 2 => { + let (mut moved, rest): (Vec, Vec) = list.drain(..).partition(|x| ids.contains(&x.id)); + list.extend(rest); + list.append(&mut moved); + } + -2 => { + let (mut moved, rest): (Vec, Vec) = list.drain(..).partition(|x| ids.contains(&x.id)); + moved.extend(rest); + *list = moved; + } + 1 => { + for i in (0..list.len().saturating_sub(1)).rev() { + let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id))); + if a == Some(true) && b == Some(false) { + list.swap(i, i + 1); + } + } + } + _ => { + for i in 1..list.len() { + let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id))); + if a == Some(false) && b == Some(true) { + list.swap(i - 1, i); + } + } + } + } +} + +/// Move one shape to `index` among its siblings (0 = back): the Selection pane's drag and drop. +/// A shape stays in the list that holds it, so a group's child stays in its group. fn reorder(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?; let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?; + let st = s.doc()?; + let siblings: &[Shape] = match deckcraft_model::parent_of(st.shapes(), id) { + None => return Err(bad("arrange.reorder", "no such shape")), + Some(None) => st.shapes(), + Some(Some(g)) => st.shape(g).map(Shape::children).unwrap_or(&[]), + }; + let from = siblings.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?; + if from == to.min(siblings.len().saturating_sub(1)) { + return Ok(json!({"index": from})); + } s.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?; - let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "not a top-level shape"))?; + let tree = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?; + let list = deckcraft_model::sibling_list_mut(tree, id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?; + let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "no such shape"))?; let sh = list.remove(i); - list.insert(to.min(list.len()), sh); - Ok(()) - })?; - ok() + let at = to.min(list.len()); + list.insert(at, sh); + Ok(json!({"index": at})) + }) } /// Bounds of shapes (rotation included). @@ -221,7 +275,7 @@ fn align(s: &mut Session, p: &Value) -> Result { } else { union_bounds(&boxes.iter().map(|b| b.1).collect::>()).unwrap_or_default() }; - edit_shapes(s, p, "arrange.align", |sh| { + edit_geometry(s, p, "arrange.align", |sh| { if let Some(x) = sh.xfrm.as_mut() { let b = x.bounds(); let (dx, dy) = match edge.as_str() { @@ -273,7 +327,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result { targets_pos.push((*id, d)); cur += (if horiz { b.width() } else { b.height() }) + gap; } - edit_shapes(s, p, "arrange.distribute", |sh| { + edit_geometry(s, p, "arrange.distribute", |sh| { if let (Some(x), Some((_, d))) = (sh.xfrm.as_mut(), targets_pos.iter().find(|(i, _)| *i == sh.id)) { if horiz { x.x += d; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 29c7013e..ce909b02 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -11,6 +11,7 @@ pub mod inspect; pub mod media; pub mod merge; pub mod review; +pub mod selection; pub mod shape; pub mod slide; pub mod table; @@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(insert::specs()); v.extend(shape::specs()); v.extend(arrange::specs()); + v.extend(selection::specs()); v.extend(merge::specs()); v.extend(text::specs()); v.extend(format::specs()); @@ -309,6 +311,18 @@ pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut }) } +/// [`edit_shapes`] for edits a locked object refuses (move, size, rotate, flip, reshape, crop): +/// locked targets are left out, and when every target is locked nothing changes. +pub(crate) fn edit_geometry(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result { + let ids = targets(s, p)?; + let st = s.doc()?; + let free: Vec = ids.iter().filter(|id| !st.shape(**id).is_some_and(|x| x.locked)).map(|id| id.0).collect(); + if !ids.is_empty() && free.is_empty() { + return Err(bad(cmd, "the object is locked")); + } + edit_shapes(s, &with_param(p, "ids", Value::from(free)), cmd, f) +} + pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &crate::Selection) -> Option<&'a Vec> { match sel.target { crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes), diff --git a/crates/engine/src/cmd/selection.rs b/crates/engine/src/cmd/selection.rs new file mode 100644 index 00000000..20890418 --- /dev/null +++ b/crates/engine/src/cmd/selection.rs @@ -0,0 +1,126 @@ +//! Selection pane: picking objects from the list, Show All and Hide All. Its other actions are +//! ordinary commands: `shape.visible` (eye), `shape.lock` (padlock), `shape.rename`, +//! `arrange.bringForward` / `arrange.sendBackward` (arrows) and `arrange.reorder` (drag and drop). + +use deckcraft_model::{Shape, ShapeId}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "selection.select", + "Select", + ["Selection Pane"], + None, + "{ids | id, toggle?: bool (Ctrl+click), add?: bool} → {selected}: objects in groups too; a hidden object picked from the list is shown", + has_slide, + select + ), + cmd!("selection.showAll", "Show All", ["Selection Pane"], None, "{} shows every object on the slide, inside groups too", has_slide, show_all), + cmd!("selection.hideAll", "Hide All", ["Selection Pane"], None, "{} hides every top-level object on the slide", has_slide, hide_all), + ] +} + +/// The Selection pane's click. Objects inside groups and top-level objects can't be selected +/// together (Ctrl+click on the other kind is ignored, as in PowerPoint), and picking a hidden +/// object from the list shows it again. +fn select(s: &mut Session, p: &Value) -> Result { + let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default(); + let (toggle, add) = (bool_or(p, "toggle", false), bool_or(p, "add", false)); + let st = s.doc()?; + let in_group = |id: &ShapeId| deckcraft_model::parent_of(st.shapes(), *id).map(|p| p.is_some()); + let mut valid: Vec = ids.iter().copied().filter(|i| in_group(i).is_some()).collect(); + if valid.is_empty() { + return Err(bad("selection.select", "no such shape")); + } + let kind = valid.first().and_then(in_group); + valid.retain(|i| in_group(i) == kind); + let keep: Vec = if toggle || add { + let current = st.selection.shapes.clone(); + if current.first().is_some_and(|c| in_group(c) != kind) { + // Can't mix objects in groups with top-level ones: nothing changes. + return Ok(json!({"selected": current})); + } + current + } else { + vec![] + }; + let mut next = keep; + for i in &valid { + match next.iter().position(|x| x == i) { + Some(k) if toggle => { + next.remove(k); + } + Some(_) => {} + None => next.push(*i), + } + } + let reveal: Vec = next.iter().copied().filter(|i| valid.contains(i) && st.shape(*i).is_some_and(|x| x.hidden)).collect(); + if reveal.is_empty() { + s.select(|_, sel| { + sel.text = None; + sel.cells = None; + sel.shapes = next.clone(); + })?; + } else { + s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.select", "no slide"))?; + for id in &reveal { + if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) { + sh.hidden = false; + } + } + sel.text = None; + sel.cells = None; + sel.shapes = next.clone(); + Ok(()) + })?; + } + Ok(json!({"selected": s.doc()?.selection.shapes})) +} + +fn show_all(s: &mut Session, _p: &Value) -> Result { + fn any_hidden(v: &[Shape]) -> bool { + let mut found = false; + deckcraft_model::walk(v, &mut |sh, _| found |= sh.hidden); + found + } + if !any_hidden(s.doc()?.shapes()) { + return ok(); + } + s.edit(|doc, sel| { + fn rec(v: &mut [Shape], depth: usize) { + for sh in v { + sh.hidden = false; + if depth < 64 + && let Some(c) = sh.children_mut() + { + rec(c, depth + 1); + } + } + } + rec(crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.showAll", "no slide"))?, 0); + Ok(()) + })?; + ok() +} + +fn hide_all(s: &mut Session, _p: &Value) -> Result { + if s.doc()?.shapes().iter().all(|x| x.hidden) { + return ok(); + } + s.edit(|doc, sel| { + for sh in crate::shapes_mut(doc, sel).ok_or_else(|| bad("selection.hideAll", "no slide"))? { + sh.hidden = true; + } + Ok(()) + })?; + ok() +} + +#[cfg(test)] +#[path = "selection_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/selection_tests.rs b/crates/engine/src/cmd/selection_tests.rs new file mode 100644 index 00000000..11d99b7c --- /dev/null +++ b/crates/engine/src/cmd/selection_tests.rs @@ -0,0 +1,264 @@ +//! Selection pane: reorder, groups, hide/show, lock and undo. + +use serde_json::{Value, json}; + +use crate::tools::{Mods, PointerEvent, PointerKind}; +use crate::*; + +/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front. +fn deck() -> (Session, Vec) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let mut ids = vec![]; + for (i, n) in ["A", "B", "C", "D", "E", "F"].iter().enumerate() { + let x = 20.0 + 110.0 * i as f64; + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 100, 80]})).unwrap(); + let id = r["id"].as_u64().unwrap() as u32; + s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap(); + ids.push(id); + } + let h = s.execute("arrange.group", &json!({"ids": [ids[4], ids[5]]})).unwrap()["id"].as_u64().unwrap() as u32; + s.execute("shape.rename", &json!({"id": h, "name": "H"})).unwrap(); + let g = s.execute("arrange.group", &json!({"ids": [ids[3], h]})).unwrap()["id"].as_u64().unwrap() as u32; + s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap(); + ids.extend([h, g]); + (s, ids) +} + +/// The tree as text, back to front: `A B C G[D H[E F]]`. +fn tree(s: &Session) -> String { + fn rec(v: &[deckcraft_model::Shape]) -> String { + v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::>().join(" ") + } + rec(s.doc().unwrap().shapes()) +} + +/// (hidden, locked) +fn flags(s: &Session, id: u32) -> (bool, bool) { + let sh = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap(); + (sh.hidden, sh.locked) +} + +fn selected(s: &Session) -> Vec { + s.doc().unwrap().selection.shapes.iter().map(|i| i.0).collect() +} + +fn undo(s: &mut Session) { + s.execute("edit.undo", &json!({})).unwrap(); +} + +fn bx(s: &mut Session, id: u32) -> Value { + s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone() +} + +#[test] +fn bring_forward_and_send_backward_stay_inside_the_group() { + let (mut s, ids) = deck(); + let (a, d, e, h) = (ids[0], ids[3], ids[4], ids[6]); + assert_eq!(tree(&s), "A B C G[D H[E F]]"); + s.execute("arrange.bringForward", &json!({"ids": [a]})).unwrap(); + assert_eq!(tree(&s), "B A C G[D H[E F]]"); + // A child moves among its siblings only and stops at the group's front. + s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap(); + assert_eq!(tree(&s), "B A C G[H[E F] D]"); + s.execute("arrange.bringForward", &json!({"ids": [d]})).unwrap(); + assert_eq!(tree(&s), "B A C G[H[E F] D]"); + s.execute("arrange.sendBackward", &json!({"ids": [e]})).unwrap(); + assert_eq!(tree(&s), "B A C G[H[E F] D]", "E is already at the back of H"); + s.execute("arrange.bringToFront", &json!({"ids": [e]})).unwrap(); + assert_eq!(tree(&s), "B A C G[H[F E] D]"); + s.execute("arrange.sendToBack", &json!({"ids": [d]})).unwrap(); + assert_eq!(tree(&s), "B A C G[D H[F E]]"); + s.execute("arrange.bringForward", &json!({"ids": [h]})).unwrap(); + assert_eq!(tree(&s), "B A C G[D H[F E]]", "H is already in front inside G"); + // Steps that changed nothing left no undo step. + undo(&mut s); + assert_eq!(tree(&s), "B A C G[H[F E] D]"); + undo(&mut s); + undo(&mut s); + assert_eq!(tree(&s), "B A C G[D H[E F]]"); +} + +#[test] +fn reorder_is_the_drag_and_drop_among_siblings() { + let (mut s, ids) = deck(); + let (a, c, d, f, g) = (ids[0], ids[2], ids[3], ids[5], ids[7]); + // Drag A to the front of the slide (an index past the end clamps). + assert_eq!(s.execute("arrange.reorder", &json!({"id": a, "index": 99})).unwrap()["index"], 3); + assert_eq!(tree(&s), "B C G[D H[E F]] A"); + s.execute("arrange.reorder", &json!({"id": g, "index": 0})).unwrap(); + assert_eq!(tree(&s), "G[D H[E F]] B C A"); + // A child is dragged within its group: it never leaves it. + s.execute("arrange.reorder", &json!({"id": d, "index": 5})).unwrap(); + assert_eq!(tree(&s), "G[H[E F] D] B C A"); + s.execute("arrange.reorder", &json!({"id": f, "index": 0})).unwrap(); + assert_eq!(tree(&s), "G[H[F E] D] B C A"); + // Dropping where it already is changes nothing (no undo step). + let n = s.doc().unwrap().history.undo.len(); + s.execute("arrange.reorder", &json!({"id": c, "index": 2})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + // One undo step per drop. + undo(&mut s); + assert_eq!(tree(&s), "G[H[E F] D] B C A"); + undo(&mut s); + undo(&mut s); + undo(&mut s); + assert_eq!(tree(&s), "A B C G[D H[E F]]"); + assert!(s.execute("arrange.reorder", &json!({"id": 4242, "index": 0})).is_err()); + assert!(s.execute("arrange.reorder", &json!({"id": a})).is_err()); +} + +#[test] +fn eye_show_all_and_hide_all() { + let (mut s, ids) = deck(); + let (a, b, d, e, g) = (ids[0], ids[1], ids[3], ids[4], ids[7]); + s.execute("shape.visible", &json!({"id": b, "visible": false})).unwrap(); + assert!(flags(&s, b).0); + // Hiding a group hides only the group; its children keep their own flags. + s.execute("shape.visible", &json!({"id": g})).unwrap(); + assert!(flags(&s, g).0 && !flags(&s, d).0); + s.execute("shape.visible", &json!({"id": e, "visible": false})).unwrap(); + // Hide All hides the top level only. + s.execute("selection.hideAll", &json!({})).unwrap(); + assert!(flags(&s, a).0 && flags(&s, g).0 && !flags(&s, d).0 && flags(&s, e).0); + // Show All shows everything, children included. + s.execute("selection.showAll", &json!({})).unwrap(); + for id in &ids { + assert!(!flags(&s, *id).0, "{id} still hidden"); + } + let n = s.doc().unwrap().history.undo.len(); + s.execute("selection.showAll", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n, "nothing to show: no undo step"); + undo(&mut s); + assert!(flags(&s, a).0 && flags(&s, e).0); + undo(&mut s); + assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0); +} + +#[test] +fn lock_cascades_through_groups_and_undoes() { + let (mut s, ids) = deck(); + let (a, d, e, h, g) = (ids[0], ids[3], ids[4], ids[6], ids[7]); + s.execute("shape.lock", &json!({"id": a})).unwrap(); + assert!(flags(&s, a).1); + s.execute("shape.lock", &json!({"id": a})).unwrap(); + assert!(!flags(&s, a).1, "toggles"); + // Locking a group locks everything in it. + s.execute("shape.lock", &json!({"id": g, "locked": true})).unwrap(); + assert!(flags(&s, g).1 && flags(&s, d).1 && flags(&s, h).1 && flags(&s, e).1); + // A child can be unlocked on its own. + s.execute("shape.lock", &json!({"id": d, "locked": false})).unwrap(); + assert!(flags(&s, g).1 && !flags(&s, d).1); + // Unlocking the group unlocks everything in it. + s.execute("shape.lock", &json!({"id": g})).unwrap(); + assert!(!flags(&s, g).1 && !flags(&s, h).1 && !flags(&s, e).1); + undo(&mut s); + assert!(flags(&s, g).1 && !flags(&s, d).1 && flags(&s, e).1); + undo(&mut s); + undo(&mut s); + assert!(!flags(&s, g).1 && !flags(&s, e).1); +} + +#[test] +fn locked_objects_refuse_moving_sizing_rotating_but_take_formatting() { + let (mut s, ids) = deck(); + let (a, b) = (ids[0], ids[1]); + s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap(); + let before = bx(&mut s, a); + for (cmd, p) in [ + ("shape.move", json!({"ids": [a], "dx": 10})), + ("shape.resize", json!({"ids": [a], "w": 300})), + ("shape.setBounds", json!({"ids": [a], "x": 1, "y": 1, "w": 2, "h": 2})), + ("shape.rotate", json!({"ids": [a], "deg": 45})), + ("shape.flip", json!({"ids": [a], "axis": "vertical"})), + ("arrange.align", json!({"ids": [a], "edge": "right"})), + ("shape.change", json!({"ids": [a], "preset": "ellipse"})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd} changed a locked shape"); + } + assert_eq!(bx(&mut s, a), before); + // Mixed selections: the unlocked ones move, the locked one stays. + let bb = bx(&mut s, b); + s.execute("shape.move", &json!({"ids": [a, b], "dx": 10})).unwrap(); + assert_eq!(bx(&mut s, a), before); + assert_eq!(bx(&mut s, b)["x"].as_f64().unwrap(), bb["x"].as_f64().unwrap() + 10.0); + // Formatting and the name still change. + s.execute("shape.fill", &json!({"ids": [a], "color": "#00ff00"})).unwrap(); + s.execute("shape.rename", &json!({"id": a, "name": "Anchor"})).unwrap(); + assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a)).unwrap().name, "Anchor"); + // The arrow keys don't nudge it. + s.execute("edit.select", &json!({"ids": [a]})).unwrap(); + let _ = s.key("ArrowRight", Mods::default()); + assert_eq!(bx(&mut s, a), before); +} + +#[test] +fn locked_objects_are_picked_on_the_canvas_but_not_dragged() { + let (mut s, ids) = deck(); + let a = ids[0]; + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap(); + s.execute("edit.select", &json!({"ids": []})).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + // A: x 20..120, y 50..130. + s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap(); + assert_eq!(selected(&s), vec![a], "a click selects a locked object"); + s.pointer(ev(PointerKind::Drag, 160.0, 190.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 160.0, 190.0)).unwrap(); + assert_eq!(bx(&mut s, a)["x"], 20.0); + // No resize handle: pressing on the SE corner and dragging doesn't size it. + s.pointer(ev(PointerKind::Down, 120.0, 130.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 200.0, 200.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 200.0, 200.0)).unwrap(); + assert_eq!(bx(&mut s, a)["w"], 100.0); + assert_eq!(s.doc().unwrap().history.undo.len(), n, "no undo steps for gestures that changed nothing"); +} + +#[test] +fn select_from_the_pane() { + let (mut s, ids) = deck(); + let (a, b, c, d, e, g) = (ids[0], ids[1], ids[2], ids[3], ids[4], ids[7]); + s.execute("selection.select", &json!({"id": b})).unwrap(); + assert_eq!(selected(&s), vec![b]); + s.execute("selection.select", &json!({"id": c, "toggle": true})).unwrap(); + assert_eq!(selected(&s), vec![b, c]); + s.execute("selection.select", &json!({"id": b, "toggle": true})).unwrap(); + assert_eq!(selected(&s), vec![c]); + s.execute("selection.select", &json!({"ids": [a, b], "add": true})).unwrap(); + assert_eq!(selected(&s), vec![c, a, b]); + // A child inside a group; children across nesting levels go together. + s.execute("selection.select", &json!({"id": d})).unwrap(); + assert_eq!(selected(&s), vec![d]); + s.execute("selection.select", &json!({"id": e, "toggle": true})).unwrap(); + assert_eq!(selected(&s), vec![d, e]); + // ...but not with top-level objects: Ctrl+click on one is ignored. + s.execute("selection.select", &json!({"id": g, "toggle": true})).unwrap(); + assert_eq!(selected(&s), vec![d, e]); + s.execute("selection.select", &json!({"id": a})).unwrap(); + s.execute("selection.select", &json!({"id": d, "toggle": true})).unwrap(); + assert_eq!(selected(&s), vec![a]); + // Picking makes no undo step... + let n = s.doc().unwrap().history.undo.len(); + s.execute("selection.select", &json!({"id": b})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + // ...unless it shows a hidden object again. + s.execute("shape.visible", &json!({"id": c, "visible": false})).unwrap(); + s.execute("selection.select", &json!({"id": c})).unwrap(); + assert_eq!(selected(&s), vec![c]); + assert!(!flags(&s, c).0, "picking a hidden object shows it"); + undo(&mut s); + assert!(flags(&s, c).0); + assert!(s.execute("selection.select", &json!({"id": 4242})).is_err()); +} + +#[test] +fn rename_from_the_pane_undoes() { + let (mut s, ids) = deck(); + let e = ids[4]; + s.execute("shape.rename", &json!({"id": e, "name": "Eagle"})).unwrap(); + assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "Eagle"); + undo(&mut s); + assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(e)).unwrap().name, "E"); +} diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 7e12f609..22cb3851 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -76,7 +76,15 @@ pub fn specs() -> Vec { cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename), cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text), cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible), - cmd!("shape.lock", "Lock", [], None, "{locked?: bool, ids?}", has_selection, lock), + cmd!( + "shape.lock", + "Lock", + ["Selection Pane"], + None, + "{locked?: bool (default: toggle), ids?} (a group locks or unlocks with everything in it)", + has_selection, + lock + ), cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default), cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop), cmd!( @@ -113,7 +121,7 @@ pub fn specs() -> Vec { fn move_by(s: &mut Session, p: &Value) -> Result { let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy")); let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y")); - edit_shapes(s, p, "shape.move", |sh| { + edit_geometry(s, p, "shape.move", |sh| { if let Some(x) = sh.xfrm.as_mut() { x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0); x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0); @@ -125,7 +133,7 @@ fn move_by(s: &mut Session, p: &Value) -> Result { fn resize(s: &mut Session, p: &Value) -> Result { let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0))); let lock = bool_or(p, "lockAspect", false); - edit_shapes(s, p, "shape.resize", |sh| { + edit_geometry(s, p, "shape.resize", |sh| { if let Some(x) = sh.xfrm.as_mut() { let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 }; let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0); @@ -154,7 +162,7 @@ fn resize(s: &mut Session, p: &Value) -> Result { } fn set_bounds(s: &mut Session, p: &Value) -> Result { - edit_shapes(s, p, "shape.setBounds", |sh| { + edit_geometry(s, p, "shape.setBounds", |sh| { if let Some(x) = sh.xfrm.as_mut() { x.x = f64_param(p, "x").unwrap_or(x.x); x.y = f64_param(p, "y").unwrap_or(x.y); @@ -167,7 +175,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { fn rotate(s: &mut Session, p: &Value) -> Result { let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by")); - edit_shapes(s, p, "shape.rotate", |sh| { + edit_geometry(s, p, "shape.rotate", |sh| { if let Some(x) = sh.xfrm.as_mut() { let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0); x.rot = ((r % 360.0) + 360.0) % 360.0; @@ -178,7 +186,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result { fn flip(s: &mut Session, p: &Value) -> Result { let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v'); - edit_shapes(s, p, "shape.flip", |sh| { + edit_geometry(s, p, "shape.flip", |sh| { if let Some(x) = sh.xfrm.as_mut() { if v { x.flip_v = !x.flip_v; @@ -193,7 +201,7 @@ fn flip(s: &mut Session, p: &Value) -> Result { fn adjust(s: &mut Session, p: &Value) -> Result { let i = usize_param(p, "index").unwrap_or(0).min(16); let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?; - edit_shapes(s, p, "shape.adjust", |sh| { + edit_geometry(s, p, "shape.adjust", |sh| { if let Geom::Preset { name, adj } = &mut sh.geom { let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]); while adj.len() <= i { @@ -211,7 +219,7 @@ fn adjust(s: &mut Session, p: &Value) -> Result { fn change(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?; let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?; - edit_shapes(s, p, "shape.change", |sh| { + edit_geometry(s, p, "shape.change", |sh| { sh.geom = Geom::preset(info.name); Ok(()) }) @@ -570,14 +578,30 @@ fn visible(s: &mut Session, p: &Value) -> Result { }) } +/// Lock: a locked object can be selected, formatted and typed in, but not moved, sized, rotated, +/// flipped, reshaped or cropped. Locking or unlocking a group does the same to everything in it +/// (a child can still be unlocked on its own afterwards), as PowerPoint does. fn lock(s: &mut Session, p: &Value) -> Result { let v = bool_param(p, "locked"); edit_shapes(s, p, "shape.lock", |sh| { - sh.locked = v.unwrap_or(!sh.locked); + let to = v.unwrap_or(!sh.locked); + set_locked(sh, to, 0); Ok(()) }) } +fn set_locked(sh: &mut deckcraft_model::Shape, to: bool, depth: usize) { + sh.locked = to; + if depth > 64 { + return; + } + if let Some(children) = sh.children_mut() { + for c in children { + set_locked(c, to, depth + 1); + } + } +} + fn set_default(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() }; @@ -595,7 +619,7 @@ fn set_default(s: &mut Session, _p: &Value) -> Result { fn crop(s: &mut Session, p: &Value) -> Result { let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")]; - edit_shapes(s, p, "picture.crop", |sh| { + edit_geometry(s, p, "picture.crop", |sh| { if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind { for (i, v) in c.iter().enumerate() { if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) { diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 876ea6e9..bf8b5c31 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -219,6 +219,10 @@ impl Session { if st.selection.text.is_none() || st.selection.shapes.len() == 1 { for id in st.selection.shapes.iter().rev() { let Some(sh) = st.shape(*id) else { continue }; + // A locked object shows no live handles: it can't be sized, rotated or reshaped. + if sh.locked { + continue; + } let x = xfrm_of(&st.doc, &st.selection, sh); if sh.is_line() { let a = x.affine(); @@ -247,8 +251,9 @@ impl Session { } } // Shapes, topmost first. + // Locked objects are still picked (to format them or type in them); they just don't move. for sh in st.shapes().iter().rev() { - if sh.hidden || sh.locked { + if sh.hidden { continue; } if let Some(h) = hit_shape(st, sh, p, tol) { @@ -423,7 +428,9 @@ impl Session { } else { vec![id] }; - let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); + // Locked objects stay put when the selection is dragged. + let origs: Vec<(ShapeId, Xfrm)> = + ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); // A click (no drag) on text puts the caret there; unselected text boxes and // placeholders take the caret straight away like PowerPoint. let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p)); @@ -433,7 +440,8 @@ impl Session { sel.text = None; sel.cells = None; })?; - self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate: mods.alt, enter_text }); + let duplicate = mods.alt && origs.len() == ids.len(); + self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate, enter_text }); // Format Painter applies on click. if self.painter.is_some() && !toggle { let _ = self.execute("format.painterApply", &json!({"ids": [id]})); @@ -464,7 +472,7 @@ impl Session { match drag { Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { let mut d = p - start; - if !moved && d.hypot() < tol.max(1.0) { + if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) { return Ok(Value::Null); } if !moved { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 75d47161..c3f0f785 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -443,6 +443,46 @@ pub fn find_shape_mut(shapes: &mut [Shape], id: ShapeId) -> Option<&mut Shape> { rec(shapes, id, 0) } +/// The list holding `id` among its siblings: the tree's top level, or a group's children. +pub fn sibling_list_mut(shapes: &mut Vec, id: ShapeId) -> Option<&mut Vec> { + fn rec(shapes: &mut Vec, id: ShapeId, depth: usize) -> Option<&mut Vec> { + if depth > 64 { + return None; + } + if shapes.iter().any(|s| s.id == id) { + return Some(shapes); + } + for s in shapes.iter_mut() { + if let ShapeKind::Group { children, .. } = &mut s.kind + && let Some(f) = rec(children, id, depth + 1) + { + return Some(f); + } + } + None + } + rec(shapes, id, 0) +} + +/// The group directly holding `id`: `Some(None)` for a top-level shape, `None` when absent. +pub fn parent_of(shapes: &[Shape], id: ShapeId) -> Option> { + fn rec(shapes: &[Shape], parent: Option, id: ShapeId, depth: usize) -> Option> { + if depth > 64 { + return None; + } + for s in shapes { + if s.id == id { + return Some(parent); + } + if let Some(f) = rec(s.children(), Some(s.id), id, depth + 1) { + return Some(f); + } + } + None + } + rec(shapes, None, id, 0) +} + /// Background of a slide, layout or master. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 4423ad1f..61a8922d 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -210,3 +210,23 @@ fn a_layout_colour_map_override_applies_to_its_slides() { assert_eq!(Ctx::for_layout(&p, m, l).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Dk1)); assert_eq!(Ctx::for_master(&p, m).color(&ColorRef::scheme(SchemeSlot::Bg1), None), colors.get(SchemeSlot::Lt1)); } + +#[test] +fn sibling_lists_and_parents_reach_into_groups() { + let leaf = |id: u32| Shape { id: ShapeId(id), ..Default::default() }; + let group = |id: u32, children: Vec| Shape { + id: ShapeId(id), + kind: ShapeKind::Group { children, child: Default::default() }, + ..Default::default() + }; + let mut tree = vec![leaf(1), group(2, vec![leaf(3), group(4, vec![leaf(5)])]), leaf(6)]; + assert_eq!(parent_of(&tree, ShapeId(1)), Some(None)); + assert_eq!(parent_of(&tree, ShapeId(3)), Some(Some(ShapeId(2)))); + assert_eq!(parent_of(&tree, ShapeId(5)), Some(Some(ShapeId(4)))); + assert_eq!(parent_of(&tree, ShapeId(9)), None); + let ids = |v: &Vec| v.iter().map(|s| s.id.0).collect::>(); + assert_eq!(sibling_list_mut(&mut tree, ShapeId(6)).map(|v| ids(v)), Some(vec![1, 2, 6])); + assert_eq!(sibling_list_mut(&mut tree, ShapeId(4)).map(|v| ids(v)), Some(vec![3, 4])); + assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5])); + assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none()); +} diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 7cb6635a..db778de0 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -104,6 +104,18 @@ fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) { } } +/// PowerPoint's Lock (right-click > Lock, the Selection pane padlock) writes a full set of locking +/// attributes; it reads an object as locked only when the whole set for its kind is there, so a +/// lone `noMove` or `noResize` (other tools' partial locks) isn't Lock. +fn is_locked(l: &El) -> bool { + let set: &[&str] = match l.local() { + "grpSpLocks" => &["noGrp", "noUngrp", "noRot", "noMove", "noResize"], + "graphicFrameLocks" => &["noGrp", "noDrilldown", "noMove", "noResize"], + _ => &["noGrp", "noRot", "noMove", "noResize", "noEditPoints", "noAdjustHandles", "noChangeArrowheads", "noChangeShapeType"], + }; + set.iter().all(|a| l.bool(a) == Some(true)) +} + /// Common non-visual properties (`p:nvSpPr`, `p:nvPicPr`…). fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape { let c = nv.and_then(|n| n.child("cNvPr")); @@ -122,10 +134,10 @@ fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape { match e.local() { "cNvSpPr" => { s.text_box = e.bool("txBox").unwrap_or(false); - s.locked = e.child("spLocks").is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true)); + s.locked = e.child("spLocks").is_some_and(is_locked); } "cNvPicPr" | "cNvGrpSpPr" | "cNvCxnSpPr" | "cNvGraphicFramePr" => { - s.locked = e.elements().next().is_some_and(|l| l.bool("noMove") == Some(true) && l.bool("noResize") == Some(true)); + s.locked = e.elements().find(|l| l.local().ends_with("Locks")).is_some_and(is_locked); } "nvPr" => { if let Some(ph) = e.child("ph") { diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index f8e415dd..8b2ea35d 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -177,8 +177,24 @@ fn nv_pr(w: &mut W, s: &Shape) { } } +/// Locking attributes. Lock writes the full set PowerPoint writes for the element's kind (and +/// reads back as locked): see `read::shapes::is_locked`. fn locks(w: &mut W, tag: &str, s: &Shape, extra: A) { - let a = extra.t("noGrp", s.ph.is_some()).t("noMove", s.locked).t("noResize", s.locked); + let a = extra.t("noGrp", s.ph.is_some() || s.locked); + let a = match tag { + "a:grpSpLocks" => a.t("noUngrp", s.locked).t("noRot", s.locked).t("noMove", s.locked).t("noResize", s.locked), + _ => { + let a = a + .t("noRot", s.locked) + .t("noMove", s.locked) + .t("noResize", s.locked) + .t("noEditPoints", s.locked) + .t("noAdjustHandles", s.locked) + .t("noChangeArrowheads", s.locked) + .t("noChangeShapeType", s.locked); + if tag == "a:picLocks" { a.t("noCrop", s.locked) } else { a } + } + }; if a.v.is_empty() { return; } @@ -458,7 +474,7 @@ fn frame_open(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) { w.open0("p:nvGraphicFramePr"); cnvpr(w, x, o, id, s); w.open0("p:cNvGraphicFramePr"); - w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noMove", s.locked).t("noResize", s.locked)); + w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noDrilldown", s.locked).t("noMove", s.locked).t("noResize", s.locked)); w.close("p:cNvGraphicFramePr"); nv_pr(w, s); w.close("p:nvGraphicFramePr"); diff --git a/crates/pptx/tests/locks.rs b/crates/pptx/tests/locks.rs new file mode 100644 index 00000000..2e903e60 --- /dev/null +++ b/crates/pptx/tests/locks.rs @@ -0,0 +1,211 @@ +//! Lock and hidden state (Selection pane): what PowerPoint writes for Lock, and what it reads as locked. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_geom::Xfrm; +use deckcraft_model::style::PictureFill; +use deckcraft_model::table::Table; +use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, Slide, defaults}; + +fn png() -> Vec { + let img = image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])); + let mut out = Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).expect("png"); + out.into_inner() +} + +fn shape(p: &mut Presentation, name: &str, x: f64) -> Shape { + Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, 40.0, 80.0, 60.0)), ..Default::default() } +} + +/// Slide 1 of a blank deck holds: a locked shape, an unlocked one, a hidden locked text box, a +/// locked group (one child unlocked, one hidden), and a locked picture, connector and table. +fn deck() -> Presentation { + let mut p = defaults::new_presentation(None); + let img = p.add_media("image1.png", "image/png", png()); + let mut v = vec![]; + let mut a = shape(&mut p, "Locked", 0.0); + a.locked = true; + v.push(a); + v.push(shape(&mut p, "Free", 100.0)); + let mut t = shape(&mut p, "Hidden text", 200.0); + t.text_box = true; + t.hidden = true; + t.locked = true; + v.push(t); + let mut c1 = shape(&mut p, "Child locked", 300.0); + c1.locked = true; + let c2 = shape(&mut p, "Child free", 400.0); + let mut c3 = shape(&mut p, "Child hidden", 500.0); + c3.hidden = true; + let gx = Xfrm::new(300.0, 40.0, 280.0, 60.0); + let mut g = Shape { + id: ShapeId(p.alloc_id()), + name: "Group".into(), + xfrm: Some(gx), + kind: ShapeKind::Group { children: vec![c1, c2, c3], child: gx }, + locked: true, + ..Default::default() + }; + g.hidden = false; + v.push(g); + let mut pic = shape(&mut p, "Pic", 0.0); + pic.kind = ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } }; + pic.locked = true; + v.push(pic); + let mut cx = shape(&mut p, "Conn", 100.0); + cx.kind = ShapeKind::Connector { start: None, end: None }; + cx.geom = deckcraft_model::Geom::preset("straightConnector1"); + cx.locked = true; + v.push(cx); + let mut tb = shape(&mut p, "Tbl", 200.0); + tb.kind = ShapeKind::Table(Table::new(2, 2, 80.0, 30.0)); + tb.locked = true; + v.push(tb); + if let Some(s) = p.slides.first_mut() { + std::sync::Arc::make_mut(s).shapes = v; + } + p +} + +fn find<'a>(s: &'a Slide, name: &str) -> &'a Shape { + let mut found = None; + deckcraft_model::walk(&s.shapes, &mut |x, _| { + if found.is_none() && x.name == name { + found = Some(x); + } + }); + found.unwrap_or_else(|| panic!("no shape {name}")) +} + +fn entries(bytes: &[u8]) -> Vec<(String, Vec)> { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + (0..z.len()) + .map(|i| { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + (f.name().to_string(), d) + }) + .collect() +} + +fn rezip(es: &[(String, Vec)]) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for (n, d) in es { + z.start_file(n.as_str(), o).expect("start"); + z.write_all(d).expect("write"); + } + z.finish().expect("finish").into_inner() +} + +fn slide1_xml(bytes: &[u8]) -> String { + let es = entries(bytes); + let (_, d) = es.iter().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1"); + String::from_utf8(d.clone()).expect("utf8") +} + +/// The element following `name="…"` up to the end of the object's non-visual properties. +fn nv_of<'a>(xml: &'a str, name: &str) -> &'a str { + let i = xml.find(&format!("name=\"{name}\"")).unwrap_or_else(|| panic!("no {name}")); + let rest = &xml[i..]; + let j = rest.find(" = s.shapes.iter().map(|x| x.name.as_str()).collect(); + assert_eq!(names, ["Locked", "Free", "Hidden text", "Group", "Pic", "Conn", "Tbl"]); + let kids: Vec<&str> = find(s, "Group").children().iter().map(|x| x.name.as_str()).collect(); + assert_eq!(kids, ["Child locked", "Child free", "Child hidden"]); +} + +#[test] +fn lock_writes_powerpoints_full_attribute_set() { + let xml = slide1_xml(&deckcraft_pptx::export(&deck()).expect("export")); + let sp = r#"noGrp="1" noRot="1" noMove="1" noResize="1" noEditPoints="1" noAdjustHandles="1" noChangeArrowheads="1" noChangeShapeType="1""#; + assert!(nv_of(&xml, "Locked").contains(&format!("")), "{}", nv_of(&xml, "Locked")); + assert!(nv_of(&xml, "Hidden text").contains(&format!(""))); + assert!(nv_of(&xml, "Hidden text").contains(r#"hidden="1""#)); + assert!(!nv_of(&xml, "Free").contains("Locks"), "an unlocked shape writes no locks: {}", nv_of(&xml, "Free")); + assert!(nv_of(&xml, "Group").contains(r#""#)); + assert!(nv_of(&xml, "Conn").contains(&format!("")), "{}", nv_of(&xml, "Conn")); + assert!(nv_of(&xml, "Pic").contains(&format!(r#""#)), "{}", nv_of(&xml, "Pic")); + assert!(nv_of(&xml, "Tbl").contains(r#""#)); +} + +/// PowerPoint reads Lock only from the whole set: a lone `noMove`/`noResize` (another tool's +/// partial lock), or the set without `noGrp`, isn't locked. +#[test] +fn partial_locks_are_not_lock() { + let mut p = defaults::new_presentation(None); + let a = shape(&mut p, "A", 0.0); + let b = shape(&mut p, "B", 100.0); + let c = shape(&mut p, "C", 200.0); + let mut d = shape(&mut p, "D", 300.0); + d.locked = true; + if let Some(s) = p.slides.first_mut() { + std::sync::Arc::make_mut(s).shapes = vec![a, b, c, d]; + } + let mut es = entries(&deckcraft_pptx::export(&p).expect("export")); + let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1"); + let xml = String::from_utf8(slide.1.clone()).expect("utf8"); + let swap = |xml: String, name: &str, locks: &str| { + let i = xml.find(&format!("name=\"{name}\"")).expect("name"); + let j = i + xml[i..].find("{locks}{}", &xml[..j], &xml[k..]) + }; + let xml = swap(xml, "A", r#""#); + let xml = swap( + xml, + "B", + r#""#, + ); + let xml = + swap(xml, "C", r#""#); + slide.1 = xml.into_bytes(); + let q = deckcraft_pptx::import(&rezip(&es)).expect("import"); + let s = &q.slides[0]; + assert!(!find(s, "A").locked); + assert!(!find(s, "B").locked); + assert!(!find(s, "C").locked); + assert!(find(s, "D").locked); +} + +/// Unlocked objects written by PowerPoint keep an empty locks element; that's not locked. +#[test] +fn empty_locks_element_is_unlocked() { + let mut p = defaults::new_presentation(None); + let a = shape(&mut p, "A", 0.0); + if let Some(s) = p.slides.first_mut() { + std::sync::Arc::make_mut(s).shapes = vec![a]; + } + let mut es = entries(&deckcraft_pptx::export(&p).expect("export")); + let slide = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide1.xml").expect("slide1"); + let xml = String::from_utf8(slide.1.clone()).expect("utf8").replacen("", "", 1); + slide.1 = xml.into_bytes(); + let q = deckcraft_pptx::import(&rezip(&es)).expect("import"); + assert!(!find(&q.slides[0], "A").locked); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 644e2f69..bf138c3f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -461,6 +461,13 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To } else { poly(painter, corners, sel_stroke); } + if sh.locked && !editing_this { + // Locked: greyed handles that don't respond, no rotate or adjust handles. + for h in box_handles(&x).0 { + painter.circle(xf.to_screen(h), 4.0, t.text_faint, Stroke::new(1.0, t.handle_fill)); + } + continue; + } if st.selection.shapes.len() == 1 || !editing_this { let (hs, rot) = box_handles(&x); let rot_s = xf.to_screen(rot); @@ -640,6 +647,11 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); }); + let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked)); + if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() { + let _ = app.run("shape.lock", json!({"locked": !locked})); + ui.close(); + } if ui.button("Link…").clicked() { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); ui.close(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index ff9897c1..aa3f2b55 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -284,6 +284,8 @@ icons! { Info => "info", Warning => "warning", Lock => "lock", + /// Open padlock (Selection pane: an object that isn't locked). + Unlock => "unlock", Eye => "eye", EyeOff => "eye-off", Close => "close", @@ -2262,22 +2264,30 @@ fn draw(p: &Pen<'_>, icon: Icon) { p.seg((12.0, 9.0), (12.0, 14.0), D); p.dot((12.0, 17.2), 1.2, D); } + // Selection pane toggles: monochrome outlines in the ink colour. I::Lock => { let mut sh = vec![(7.5, 10.5), (7.5, 8.0)]; sh.extend(arc_pts((12.0, 8.0), 4.5, 4.5, 180.0, 360.0)); sh.push((16.5, 10.5)); p.line(&sh, K); - p.rect(4.5, 10.5, 19.5, 21.5, 1.8, Y, K); - p.dot((12.0, 15.0), 1.5, D); - p.seg((12.0, 15.5), (12.0, 18.3), D); + p.rect(4.5, 10.5, 19.5, 21.5, 1.8, N, K); + p.seg((12.0, 14.5), (12.0, 17.5), K); + } + I::Unlock => { + // The shackle swung open: its left leg out of the body, raised. + let mut sh = vec![(12.0, 10.5), (12.0, 6.0)]; + sh.extend(arc_pts((16.5, 6.0), 4.5, 4.5, 180.0, 360.0)); + sh.push((21.0, 8.5)); + p.line(&sh, K); + p.rect(3.0, 10.5, 16.5, 21.5, 1.8, N, K); + p.seg((9.75, 14.5), (9.75, 17.5), K); } I::Eye | I::EyeOff => { - let mut e = quad_pts((2.0, 12.0), (12.0, 2.5), (22.0, 12.0), 16); - e.extend(quad_pts((22.0, 12.0), (12.0, 21.5), (2.0, 12.0), 16).into_iter().skip(1)); + let mut e = quad_pts((2.0, 12.0), (12.0, 4.0), (22.0, 12.0), 16); + e.extend(quad_pts((22.0, 12.0), (12.0, 20.0), (2.0, 12.0), 16).into_iter().skip(1)); e.pop(); p.shape(&e, N, K); - p.circle((12.0, 12.0), 3.4, B, N); - p.dot((12.0, 12.0), 1.3, if p.dis { N } else { W }); + p.circle((12.0, 12.0), 3.2, N, K); if icon == I::EyeOff { p.seg_w((4.0, 21.0), (20.0, 3.0), K, 1.1); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1d786b21..25a8323f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod media; pub mod menus; pub mod panes; pub mod ribbon; +pub mod selpane; pub mod show; pub mod sorter; pub mod status; @@ -219,6 +220,8 @@ pub struct SlideApp { pub ui_fonts: uifonts::UiFonts, /// Thumbnail pane drag (slide index, current drop index). pub(crate) thumb_drag: Option<(usize, usize)>, + /// Selection pane: folded groups, renaming, drag and drop. + pub selpane: selpane::State, } impl SlideApp { @@ -258,6 +261,7 @@ impl SlideApp { notes_buf: (0, usize::MAX, String::new()), ui_fonts: uifonts::UiFonts::default(), thumb_drag: None, + selpane: selpane::State::default(), } } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index fff1da46..2b3ada3a 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -47,7 +47,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "format" => format_shape(app, ui), "background" => background(app, ui), "animation" => animation(app, ui), - "selection" => selection(app, ui), + "selection" => crate::selpane::show(app, ui), "comments" => comments(app, ui), "designer" => designer(app, ui), _ => {} @@ -549,72 +549,6 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { let _ = Stroke::NONE; } -fn selection(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { return }; - let shapes: Vec<(u32, String, bool, &'static str)> = - st.shapes().iter().rev().map(|s| (s.id.0, s.name.clone(), s.hidden, s.kind_name())).collect(); - let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); - ui.horizontal(|ui| { - if ui.button("Show All").clicked() { - let ids: Vec = shapes.iter().map(|s| s.0).collect(); - run(app, "shape.visible", json!({"ids": ids, "visible": true})); - } - if ui.button("Hide All").clicked() { - let ids: Vec = shapes.iter().map(|s| s.0).collect(); - run(app, "shape.visible", json!({"ids": ids, "visible": false})); - } - }); - ui.separator(); - let n = shapes.len(); - for (k, (id, name, hidden, kind)) in shapes.into_iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); - let sel = selected.contains(&id); - if sel || resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(4), if sel { t.selected } else { t.hover }); - } - ui.painter().text( - pos2(r.min.x + 8.0, r.center().y), - Align2::LEFT_CENTER, - &name, - theme::font(12.5), - if hidden { t.text_faint } else { t.text }, - ); - ui.painter().text(pos2(r.max.x - 34.0, r.center().y), Align2::RIGHT_CENTER, kind, theme::font(10.5), t.text_faint); - let eye = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); - icons::paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, t.text_dim, false); - let eye_resp = ui.interact(eye, ui.id().with(("eye", id)), Sense::click()); - if eye_resp.clicked() { - run(app, "shape.visible", json!({"id": id, "visible": hidden})); - } else if resp.clicked() { - let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); - run(app, "edit.select", json!({"ids": [id], "toggle": add})); - } - if resp.double_clicked() { - let mut d = crate::dialogs::Dialog::new("renameShape"); - d.params = json!({"id": id, "name": name}); - app.dialog = Some(d); - } - resp.context_menu(|ui| { - if ui.button("Bring Forward").clicked() { - run(app, "arrange.bringForward", json!({"ids": [id]})); - ui.close(); - } - if ui.button("Send Backward").clicked() { - run(app, "arrange.sendBackward", json!({"ids": [id]})); - ui.close(); - } - if ui.button("Rename…").clicked() { - let mut d = crate::dialogs::Dialog::new("renameShape"); - d.params = json!({"id": id, "name": name}); - app.dialog = Some(d); - ui.close(); - } - }); - let _ = (k, n); - } -} - fn comments(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); if ui.button("+ New Comment").clicked() { diff --git a/crates/ui-egui/src/selpane.rs b/crates/ui-egui/src/selpane.rs new file mode 100644 index 00000000..414a61f5 --- /dev/null +++ b/crates/ui-egui/src/selpane.rs @@ -0,0 +1,412 @@ +//! Selection pane: every object on the slide, front-most first, groups expanded with their +//! children. Click to select (Ctrl toggles, Shift selects a run), drag to restack among +//! siblings, eye to show or hide, padlock to lock, double-click or F2 to rename, arrows to move +//! through the list. Everything it changes goes through engine commands. + +use std::collections::HashSet; + +use deckcraft_model::Shape; +use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2}; +use serde_json::json; + +use crate::SlideApp; +use crate::icons::{self, Icon}; +use crate::theme::{self, Tokens}; + +pub const ROW_H: f32 = 24.0; +const INDENT: f32 = 14.0; + +/// Pane-only state (nothing here is part of the document). +#[derive(Debug, Default)] +pub struct State { + /// Groups folded shut. + pub collapsed: HashSet, + /// Object being renamed and the text typed so far. + pub renaming: Option<(u32, String)>, + rename_started: bool, + /// Last row clicked without Shift (the start of a Shift+click run). + pub anchor: Option, + /// Row being dragged and the drop it would make: (index among siblings, line y). + pub drag: Option<(u32, Option<(usize, f32)>)>, +} + +/// One line of the list. +#[derive(Clone, Debug, PartialEq)] +pub struct Row { + pub id: u32, + pub name: String, + pub depth: usize, + pub group: bool, + pub hidden: bool, + /// Inside a hidden group: drawn dimmed. + pub dimmed: bool, + pub locked: bool, + pub parent: Option, +} + +/// The rows as listed: front-most first, children under their group unless it's folded. +pub fn rows(shapes: &[Shape], collapsed: &HashSet) -> Vec { + fn rec(v: &[Shape], depth: usize, parent: Option, dim: bool, collapsed: &HashSet, out: &mut Vec) { + if depth > 64 { + return; + } + for sh in v.iter().rev() { + out.push(Row { + id: sh.id.0, + name: sh.name.clone(), + depth, + group: sh.is_group(), + hidden: sh.hidden, + dimmed: dim, + locked: sh.locked, + parent, + }); + if sh.is_group() && !collapsed.contains(&sh.id.0) { + rec(sh.children(), depth + 1, Some(sh.id.0), dim || sh.hidden, collapsed, out); + } + } + } + let mut out = vec![]; + rec(shapes, 0, None, false, collapsed, &mut out); + out +} + +/// Where a dragged row would land: among the rows sharing its parent (`rects`: each sibling's +/// block, the group's children included, top to bottom), the gap nearest `y`. Returns the index +/// among the siblings in the file's order (0 = back) and the y of the insertion line. +pub fn drop_target(dragged: u32, siblings: &[(u32, Rect)], y: f32) -> Option<(usize, f32)> { + let others: Vec<&(u32, Rect)> = siblings.iter().filter(|(id, _)| *id != dragged).collect(); + let first = others.first()?; + let mut gaps: Vec = vec![first.1.top()]; + gaps.extend(others.iter().map(|(_, r)| r.bottom())); + let (g, gy) = gaps.iter().enumerate().min_by(|a, b| (a.1 - y).abs().total_cmp(&(b.1 - y).abs()))?; + // `g` siblings sit above the line; the dragged object goes behind them. + Some((others.len() - g, *gy)) +} + +fn run(app: &mut SlideApp, id: &str, p: serde_json::Value) { + let _ = app.run(id, p); +} + +/// Each row is a widget with an id of its own (keyboard focus sits on the selected row). +fn row_id(id: u32) -> Id { + Id::new(("selection_pane_row", id)) +} + +fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, label)); + if enabled && resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover); + } + ui.painter().rect_stroke(rect, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + icons::paint(ui.painter(), rect.shrink(4.0), icon, t.text, !enabled); + resp.on_hover_text(label) +} + +pub fn show(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let rows = rows(st.shapes(), &app.selpane.collapsed); + let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); + let has_sel = !selected.is_empty(); + + ui.horizontal(|ui| { + if ui.add(egui::Button::new("Show All").stroke(Stroke::new(1.0, t.border))).clicked() { + run(app, "selection.showAll", json!({})); + } + if ui.add(egui::Button::new("Hide All").stroke(Stroke::new(1.0, t.border))).clicked() { + run(app, "selection.hideAll", json!({})); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if small_icon_button(ui, Icon::ChevronDown, "Send Backward", has_sel).clicked() { + run(app, "arrange.sendBackward", json!({})); + } + if small_icon_button(ui, Icon::ChevronUp, "Bring Forward", has_sel).clicked() { + run(app, "arrange.bringForward", json!({})); + } + }); + }); + ui.add_space(6.0); + + // The list has the keyboard while one of its rows has focus. + let focused_row = ui.memory(|m| m.focused()).and_then(|f| rows.iter().find(|r| row_id(r.id) == f).map(|r| r.id)); + if let Some(f) = focused_row { + ui.memory_mut(|m| { + m.set_focus_lock_filter(row_id(f), egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: true, escape: true }) + }); + } + let mut rects: Vec = Vec::with_capacity(rows.len()); + let width = ui.available_width(); + for (k, row) in rows.iter().enumerate() { + let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover()); + let resp = ui.interact(r, row_id(row.id), Sense::click_and_drag()); + rects.push(r); + let sel = selected.contains(&row.id); + resp.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, true, sel, &row.name)); + let painter = ui.painter_at(r); + if sel { + painter.rect_filled(r, CornerRadius::same(3), t.selected); + painter.rect_stroke(r.shrink(0.5), CornerRadius::same(3), Stroke::new(1.0, t.text_faint), egui::StrokeKind::Inside); + } else if resp.hovered() { + painter.rect_filled(r, CornerRadius::same(3), t.hover); + } + let x0 = r.min.x + 4.0 + INDENT * row.depth as f32; + // Group chevron. + if row.group { + let open = !app.selpane.collapsed.contains(&row.id); + let cr = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); + let cresp = ui.interact(cr, ui.id().with(("fold", row.id)), Sense::click()); + let label = format!("{} {}", if open { "Collapse" } else { "Expand" }, row.name); + cresp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label)); + icons::paint(&painter, cr, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, false); + if cresp.clicked() { + if open { + app.selpane.collapsed.insert(row.id); + } else { + app.selpane.collapsed.remove(&row.id); + } + } + } + let name_x = x0 + 16.0; + let eye = Rect::from_center_size(pos2(r.max.x - 34.0, r.center().y), vec2(16.0, 16.0)); + let lock = Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(16.0, 16.0)); + let name_rect = Rect::from_min_max(pos2(name_x, r.min.y + 2.0), pos2(eye.min.x - 6.0, r.max.y - 2.0)); + let renaming = app.selpane.renaming.as_ref().filter(|(id, _)| *id == row.id).map(|(_, s)| s.clone()); + if let Some(mut buf) = renaming { + let edit_id = ui.id().with(("rename", row.id)); + let out = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(theme::font(12.5)).margin(vec2(2.0, 1.0))); + if !app.selpane.rename_started { + out.request_focus(); + if let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), edit_id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count())); + s.cursor.set_char_range(Some(all)); + s.store(ui.ctx(), edit_id); + } + app.selpane.rename_started = true; + } + let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + if out.lost_focus() || enter || esc { + app.selpane.renaming = None; + app.selpane.rename_started = false; + if !esc && buf.trim() != row.name && !buf.trim().is_empty() { + run(app, "shape.rename", json!({"id": row.id, "name": buf.trim()})); + } + ui.memory_mut(|m| m.request_focus(row_id(row.id))); + } else if let Some((_, s)) = app.selpane.renaming.as_mut() { + *s = buf; + } + } else { + let color = if row.dimmed { t.text_faint } else { t.text }; + painter.text(pos2(name_x, r.center().y), Align2::LEFT_CENTER, &row.name, theme::font(12.5), color); + } + // Eye and padlock. + let ink = if row.dimmed { t.text_faint } else { t.text_dim }; + icons::paint(&painter, eye, if row.hidden { Icon::EyeOff } else { Icon::Eye }, ink, false); + icons::paint(&painter, lock, if row.locked { Icon::Lock } else { Icon::Unlock }, ink, false); + let eye_resp = ui.interact(eye.expand(3.0), ui.id().with(("eye", row.id)), Sense::click()); + let eye_label = format!("{} {}", if row.hidden { "Show" } else { "Hide" }, row.name); + eye_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &eye_label)); + let lock_resp = ui.interact(lock.expand(3.0), ui.id().with(("lock", row.id)), Sense::click()); + let lock_label = format!("{} {}", if row.locked { "Unlock" } else { "Lock" }, row.name); + lock_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &lock_label)); + if eye_resp.clicked() { + run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden})); + } else if lock_resp.clicked() { + run(app, "shape.lock", json!({"id": row.id})); + } else if resp.double_clicked() { + app.selpane.renaming = Some((row.id, row.name.clone())); + app.selpane.rename_started = false; + } else if resp.clicked() { + let m = ui.input(|i| i.modifiers); + click(app, &rows, k, m.command || m.ctrl, m.shift); + ui.memory_mut(|mem| mem.request_focus(row_id(row.id))); + } + if resp.drag_started() { + if !sel { + run(app, "selection.select", json!({"id": row.id})); + } + app.selpane.drag = Some((row.id, None)); + } + context_menu(app, &resp, row); + } + + // Dragging: the insertion line among the dragged row's siblings. + if let Some((dragged, _)) = app.selpane.drag { + let pointer = ui.input(|i| i.pointer.interact_pos()); + let released = ui.input(|i| i.pointer.any_released() || !i.pointer.any_down()); + let target = pointer.and_then(|p| { + let parent = rows.iter().find(|r| r.id == dragged).map(|r| r.parent)?; + let blocks = sibling_blocks(&rows, &rects, parent); + drop_target(dragged, &blocks, p.y) + }); + app.selpane.drag = Some((dragged, target)); + if let Some((_, y)) = target { + let (x0, x1) = (ui.min_rect().left(), ui.min_rect().left() + width); + let p = ui.painter(); + let s = Stroke::new(2.0, t.accent); + p.line_segment([pos2(x0, y), pos2(x1, y)], s); + p.line_segment([pos2(x0, y - 4.0), pos2(x0, y + 4.0)], s); + p.line_segment([pos2(x1, y - 4.0), pos2(x1, y + 4.0)], s); + } + if released { + app.selpane.drag = None; + if let Some((index, _)) = target { + run(app, "arrange.reorder", json!({"id": dragged, "index": index})); + } + } + } + + // Keys while the list has focus. + if let Some(f) = focused_row + && app.selpane.renaming.is_none() + { + keys(app, ui, &rows, &selected, f); + } + // Clicking the empty part of the list doesn't change anything; keep the pane's height. + let rest = ui.available_size(); + if rest.y > 0.0 { + ui.allocate_exact_size(vec2(width, rest.y.min(400.0)), Sense::hover()); + } +} + +/// Each sibling of `parent`'s block (its row plus its shown children), top to bottom. +fn sibling_blocks(rows: &[Row], rects: &[Rect], parent: Option) -> Vec<(u32, Rect)> { + let mut out: Vec<(u32, Rect)> = vec![]; + let depth_of = |id: Option| id.and_then(|p| rows.iter().find(|r| r.id == p)).map(|r| r.depth + 1).unwrap_or(0); + let depth = depth_of(parent); + let mut current: Option<(u32, Rect)> = None; + for (row, rect) in rows.iter().zip(rects) { + if row.parent == parent { + if let Some(c) = current.take() { + out.push(c); + } + current = Some((row.id, *rect)); + } else if row.depth > depth + && let Some((_, r)) = current.as_mut() + { + *r = r.union(*rect); + } else if let Some(c) = current.take() { + out.push(c); + } + } + if let Some(c) = current { + out.push(c); + } + out +} + +fn click(app: &mut SlideApp, rows: &[Row], k: usize, ctrl: bool, shift: bool) { + let Some(row) = rows.get(k) else { return }; + if shift && let Some(a) = app.selpane.anchor.and_then(|a| rows.iter().position(|r| r.id == a)) { + // A run of rows of the same kind (top level, or inside groups) between the anchor and here. + let (lo, hi) = if a <= k { (a, k) } else { (k, a) }; + let top = row.parent.is_none(); + let ids: Vec = rows.get(lo..=hi).unwrap_or(&[]).iter().filter(|r| r.parent.is_none() == top).map(|r| r.id).collect(); + run(app, "selection.select", json!({"ids": ids, "add": ctrl})); + return; + } + run(app, "selection.select", json!({"id": row.id, "toggle": ctrl})); + app.selpane.anchor = Some(row.id); +} + +fn keys(app: &mut SlideApp, ui: &Ui, rows: &[Row], selected: &[u32], focused: u32) { + let (up, down, home, end, f2, esc) = ui.input(|i| { + ( + i.key_pressed(egui::Key::ArrowUp), + i.key_pressed(egui::Key::ArrowDown), + i.key_pressed(egui::Key::Home), + i.key_pressed(egui::Key::End), + i.key_pressed(egui::Key::F2), + i.key_pressed(egui::Key::Escape), + ) + }); + let cur = selected.last().and_then(|id| rows.iter().position(|r| r.id == *id)); + let to = if up { + Some(cur.map(|c| c.saturating_sub(1)).unwrap_or(0)) + } else if down { + Some(cur.map(|c| (c + 1).min(rows.len().saturating_sub(1))).unwrap_or(0)) + } else if home { + Some(0) + } else if end { + Some(rows.len().saturating_sub(1)) + } else { + None + }; + if let Some(k) = to + && let Some(r) = rows.get(k) + && Some(k) != cur + { + run(app, "selection.select", json!({"id": r.id})); + app.selpane.anchor = Some(r.id); + ui.memory_mut(|m| m.request_focus(row_id(r.id))); + } + if f2 + && let [one] = selected + && let Some(r) = rows.iter().find(|r| r.id == *one) + { + app.selpane.renaming = Some((r.id, r.name.clone())); + app.selpane.rename_started = false; + } + if esc { + ui.memory_mut(|m| m.surrender_focus(row_id(focused))); + } +} + +fn context_menu(app: &mut SlideApp, resp: &egui::Response, row: &Row) { + resp.context_menu(|ui| { + for (label, cmd) in [ + ("Bring to Front", "arrange.bringToFront"), + ("Bring Forward", "arrange.bringForward"), + ("Send Backward", "arrange.sendBackward"), + ("Send to Back", "arrange.sendToBack"), + ] { + if ui.button(label).clicked() { + run(app, cmd, json!({"ids": [row.id]})); + ui.close(); + } + } + ui.separator(); + if ui.button("Rename").clicked() { + app.selpane.renaming = Some((row.id, row.name.clone())); + app.selpane.rename_started = false; + ui.close(); + } + if ui.button(if row.hidden { "Show" } else { "Hide" }).clicked() { + run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden})); + ui.close(); + } + if ui.button(if row.locked { "Unlock" } else { "Lock" }).clicked() { + run(app, "shape.lock", json!({"id": row.id})); + ui.close(); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(id: u32, top: f32) -> (u32, Rect) { + (id, Rect::from_min_size(pos2(0.0, top), vec2(100.0, ROW_H))) + } + + #[test] + fn drop_target_picks_the_nearest_gap() { + // Rows top to bottom: 4 (front), 3, 2, 1 (back); dragging 1. + let sib = [r(4, 0.0), r(3, 24.0), r(2, 48.0), r(1, 72.0)]; + // Above everything: in front of all three others. + assert_eq!(drop_target(1, &sib, -5.0), Some((3, 0.0))); + // Between 4 and 3: one sibling above → index 2. + assert_eq!(drop_target(1, &sib, 22.0), Some((2, 24.0))); + // Below the last other row: the back. + assert_eq!(drop_target(1, &sib, 500.0), Some((0, 72.0))); + // Nothing else to drop among. + assert_eq!(drop_target(1, &[r(1, 0.0)], 10.0), None); + } +} + +#[cfg(test)] +#[path = "selpane_tests.rs"] +mod ui_tests; diff --git a/crates/ui-egui/src/selpane_tests.rs b/crates/ui-egui/src/selpane_tests.rs new file mode 100644 index 00000000..2c2181dc --- /dev/null +++ b/crates/ui-egui/src/selpane_tests.rs @@ -0,0 +1,255 @@ +//! The Selection pane driven like a person would: clicks, Ctrl+click, drag and drop, the eye, +//! the padlock, renaming and the keyboard. + +use deckcraft_engine::Session; +use egui::{Modifiers, Pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// A blank slide with A, B, C (back to front) and the group G = [D, E] in front; the pane open. +fn harness() -> (Harness<'static, SlideApp>, Vec) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let mut ids = vec![]; + for (i, n) in ["A", "B", "C", "D", "E"].iter().enumerate() { + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [20.0 + 110.0 * i as f64, 50, 100, 80]})).unwrap(); + let id = r["id"].as_u64().unwrap() as u32; + s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap(); + ids.push(id); + } + let g = s.execute("arrange.group", &json!({"ids": [ids[3], ids[4]]})).unwrap()["id"].as_u64().unwrap() as u32; + s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap(); + s.execute("edit.select", &json!({"ids": []})).unwrap(); + ids.push(g); + let mut app = SlideApp::new(s, Services::default()); + app.ui.pane = Some("selection".into()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + (h, ids) +} + +fn names(h: &Harness<'_, SlideApp>) -> String { + fn rec(v: &[deckcraft_model::Shape]) -> String { + v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::>().join(" ") + } + rec(h.state().session.active().unwrap().shapes()) +} + +fn selected(h: &Harness<'_, SlideApp>) -> Vec { + let st = h.state().session.active().unwrap(); + st.selection.shapes.iter().filter_map(|i| st.shape(*i)).map(|s| s.name.clone()).collect() +} + +fn row(h: &Harness<'_, SlideApp>, name: &str) -> egui::Rect { + h.get_by_label(name).rect() +} + +fn shape<'a>(h: &'a Harness<'_, SlideApp>, name: &str) -> &'a deckcraft_model::Shape { + let st = h.state().session.active().unwrap(); + let mut found = None; + deckcraft_model::walk(st.shapes(), &mut |s, _| { + if found.is_none() && s.name == name { + found = Some(s); + } + }); + found.unwrap() +} + +#[test] +fn lists_front_most_first_with_groups_expanded() { + let (h, _) = harness(); + let ys: Vec = ["G", "D", "E", "C", "B", "A"].iter().map(|n| row(&h, n).center().y).collect(); + // G is in front, then its children front-most first (E above D), then C, B, A. + let order = ["G", "E", "D", "C", "B", "A"]; + let mut by_y: Vec<(&str, f32)> = ["G", "D", "E", "C", "B", "A"].iter().copied().zip(ys).collect(); + by_y.sort_by(|a, b| a.1.total_cmp(&b.1)); + assert_eq!(by_y.iter().map(|x| x.0).collect::>(), order); + // Children are indented under the group (the name's row starts at the same x; the group + // has a fold button). + assert!(h.query_by_label("Collapse G").is_some()); + assert!(h.query_by_label("Hide A").is_some() && h.query_by_label("Lock A").is_some()); +} + +#[test] +fn click_ctrl_click_and_shift_click_select() { + let (mut h, _) = harness(); + h.get_by_label("A").click(); + h.run_steps(2); + assert_eq!(selected(&h), ["A"]); + h.get_by_label("C").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + assert_eq!(selected(&h), ["A", "C"]); + h.get_by_label("A").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + assert_eq!(selected(&h), ["C"]); + // Shift+click: the run from the last click (C) down to A. + h.get_by_label("C").click(); + h.run_steps(2); + h.get_by_label("A").click_modifiers(Modifiers::SHIFT); + h.run_steps(2); + let mut sel = selected(&h); + sel.sort(); + assert_eq!(sel, ["A", "B", "C"]); + // A child of the group. + h.get_by_label("D").click(); + h.run_steps(2); + assert_eq!(selected(&h), ["D"]); +} + +#[test] +fn eye_and_padlock_toggle_and_undo() { + let (mut h, _) = harness(); + h.get_by_label("Hide B").click(); + h.run_steps(2); + assert!(shape(&h, "B").hidden); + assert!(h.query_by_label("Show B").is_some(), "the eye now offers to show it"); + h.get_by_label("Lock G").click(); + h.run_steps(2); + assert!(shape(&h, "G").locked && shape(&h, "D").locked && shape(&h, "E").locked, "the group locks with its children"); + h.get_by_label("Unlock D").click(); + h.run_steps(2); + assert!(!shape(&h, "D").locked && shape(&h, "G").locked); + h.get_by_label("Hide All").click(); + h.run_steps(2); + assert!(["A", "B", "C", "G"].iter().all(|n| shape(&h, n).hidden)); + h.get_by_label("Show All").click(); + h.run_steps(2); + assert!(["A", "B", "C", "G", "D", "E"].iter().all(|n| !shape(&h, n).hidden)); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(shape(&h, "A").hidden); +} + +/// Drag a row with the mouse: press, move in steps, release. +fn drag(h: &mut Harness<'_, SlideApp>, from: Pos2, to: Pos2) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for i in 1..=6 { + let t = i as f32 / 6.0; + h.hover_at(from + (to - from) * t); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); +} + +#[test] +fn drag_and_drop_restacks_among_siblings() { + let (mut h, _) = harness(); + assert_eq!(names(&h), "A B C G[D E]"); + // A (bottom row) dragged above G (top row): A goes to the front. + let (a, g) = (row(&h, "A"), row(&h, "G")); + drag(&mut h, a.center(), Pos2::new(g.center().x, g.top() + 2.0)); + assert_eq!(names(&h), "B C G[D E] A"); + assert_eq!(selected(&h), ["A"], "the dragged object is selected"); + // C dragged to the bottom of the list: the back. + let (c, b) = (row(&h, "C"), row(&h, "B")); + drag(&mut h, c.center(), Pos2::new(b.center().x, b.bottom() + 30.0)); + assert_eq!(names(&h), "C B G[D E] A"); + // D (a child) dragged far above its group stays in the group, at its front. + let (d, a) = (row(&h, "D"), row(&h, "A")); + drag(&mut h, d.center(), Pos2::new(d.center().x, a.top() - 10.0)); + assert_eq!(names(&h), "C B G[E D] A"); + // B dropped over the group lands among the top-level objects (here just in front of the + // group), never inside it. + let (b, g) = (row(&h, "B"), row(&h, "G")); + drag(&mut h, b.center(), Pos2::new(g.center().x, g.center().y + 6.0)); + assert_eq!(names(&h), "C G[E D] B A"); + // Each drop is one undo step. + for _ in 0..4 { + h.state_mut().run("edit.undo", json!({})).unwrap(); + } + assert_eq!(names(&h), "A B C G[D E]"); +} + +#[test] +fn bring_forward_and_send_backward_buttons() { + let (mut h, _) = harness(); + h.get_by_label("A").click(); + h.run_steps(2); + h.get_by_label("Bring Forward").click(); + h.run_steps(2); + assert_eq!(names(&h), "B A C G[D E]"); + h.get_by_label("D").click(); + h.run_steps(2); + h.get_by_label("Bring Forward").click(); + h.run_steps(2); + h.get_by_label("Bring Forward").click(); + h.run_steps(2); + assert_eq!(names(&h), "B A C G[E D]", "a child stays in its group"); + h.get_by_label("Send Backward").click(); + h.run_steps(2); + assert_eq!(names(&h), "B A C G[D E]"); +} + +#[test] +fn double_click_renames_in_place() { + let (mut h, _) = harness(); + let b = row(&h, "B").center(); + h.hover_at(b); + h.run_steps(1); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + } + h.run_steps(2); + assert!(h.state().selpane.renaming.is_some(), "double-click starts renaming"); + // The whole name is selected: typing replaces it. + h.event(egui::Event::Text("Bravo".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(shape(&h, "Bravo").id, shape(&h, "Bravo").id); + assert!(h.state().selpane.renaming.is_none()); + // Escape cancels. + h.get_by_label("Bravo").click(); + h.run_steps(2); + h.key_press(egui::Key::F2); + h.run_steps(3); + assert!(h.state().selpane.renaming.is_some(), "F2 renames the selected object"); + h.event(egui::Event::Text("Nope".into())); + h.run_steps(1); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.query_by_label("Bravo").is_some()); + assert!(h.query_by_label("Nope").is_none()); +} + +#[test] +fn arrow_keys_move_through_the_list_and_collapse_hides_children() { + let (mut h, _) = harness(); + h.get_by_label("G").click(); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + assert_eq!(selected(&h), ["E"]); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + assert_eq!(selected(&h), ["C"]); + // The arrows moved through the list: C itself didn't move on the slide. + assert_eq!(shape(&h, "C").xfrm.map(|x| x.x), Some(240.0)); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(selected(&h), ["D"]); + h.get_by_label("Collapse G").click(); + h.run_steps(2); + assert!(h.query_by_label("D").is_none() && h.query_by_label("Expand G").is_some()); + h.get_by_label("Expand G").click(); + h.run_steps(2); + assert!(h.query_by_label("D").is_some()); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07ae91c2..7ac73188 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -29,7 +29,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa |---|---|---| | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | | Format Background pane | yes | yes | -| Selection Pane | yes | yes | +| Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | | Design Ideas (Designer) | yes (cloud) | no | @@ -89,4 +89,6 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | + +| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock | | 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 | From 6334d689d2d245acebe7d9e3cc793741e7094474 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 22:09:50 -0400 Subject: [PATCH 38/49] Text: Ctrl+arrows, Ctrl+Backspace/Delete and other text keys work like PowerPoint (#268) (#333) * Text: Ctrl+arrows, Ctrl+Backspace/Delete and the other text keys work like PowerPoint (#268) Observed black-box in PowerPoint for Windows on synthetic text: - Word stops (crates/model/src/words.rs): words, punctuation runs, symbol runs, tabs, line breaks and each CJK character are tokens; trailing spaces join the token before. it's, a-b, 3.14, 1,000 are one word; e.g., a_b and x@y.com are not. Grapheme clusters never split. - Ctrl+Left/Right and Ctrl+Shift+Left/Right move by those stops, across paragraphs; with a selection they go on from its start/end. In a right-to-left paragraph Left and Right swap. - Ctrl+Backspace deletes to the previous stop; Ctrl+Delete deletes the rest of the word, or spaces plus the next word, then applies PowerPoint's smart spacing. With a selection both work from its start. - Ctrl+Up/Down by paragraph, Ctrl+Home/End to the text's ends. - End on a wrapped line goes past the trailing space and the caret stays drawn at that line's end (TextSel::line_end); End again stays, Home returns to the same line. - Ctrl+]/[ and Ctrl+Shift+>/< change font size (egui's Period/Comma/ bracket key names now match registry shortcuts). - Shift+F3 cycles lower -> Title -> UPPER; a caret changes its word. - Double-click selects the word without trailing spaces; triple-click includes the paragraph mark. - Mac keeps Option for words and Cmd for line/text ends (Session::mac_keys). Co-Authored-By: Claude Opus 5.5 (1M context) * rustfmt * Canvas: one caret, drawn at a wrapped line's end after End Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + ROADMAP.md | 2 + crates/engine/src/cmd/format.rs | 44 +++- crates/engine/src/cmd/text.rs | 76 ++++-- crates/engine/src/keys.rs | 90 ++++++- crates/engine/src/lib.rs | 19 ++ crates/engine/src/tests_keys.rs | 404 ++++++++++++++++++++++++++++++ crates/model/Cargo.toml | 1 + crates/model/src/edit.rs | 293 ++++++++++++++++++---- crates/model/src/lib.rs | 1 + crates/model/src/words.rs | 420 ++++++++++++++++++++++++++++++++ crates/text/src/lib.rs | 58 ++++- crates/ui-egui/src/lib.rs | 83 +++++++ docs/ui-parity.md | 33 +++ 14 files changed, 1440 insertions(+), 85 deletions(-) create mode 100644 crates/engine/src/tests_keys.rs create mode 100644 crates/model/src/words.rs diff --git a/Cargo.lock b/Cargo.lock index e8c93986..f4d53470 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1063,6 +1063,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "unicode-segmentation", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 0f1e02fb..c93e1fdc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,6 +133,8 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | + +| 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection | | 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index f02a9a26..c68d1242 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -49,7 +49,15 @@ pub fn specs() -> Vec { color ), cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight), - cmd!("format.case", "Change Case", ["Home", "Font"], Some("Shift+F3"), "{mode: sentence|lower|upper|title|toggle}", has_text_or_shapes, case), + cmd!( + "format.case", + "Change Case", + ["Home", "Font"], + Some("Shift+F3"), + "{mode?: cycle|sentence|lower|upper|title|toggle} — cycle (Shift+F3, the default) goes lowercase → Capitalize Each Word → UPPERCASE; a caret in a word changes that word", + has_text_or_shapes, + case + ), cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps), cmd!( "format.spacing", @@ -328,15 +336,23 @@ fn text_outline(s: &mut Session, p: &Value) -> Result { } fn case(s: &mut Session, p: &Value) -> Result { - let mode = str_param(p, "mode").unwrap_or("sentence").to_string(); + let mode = str_param(p, "mode").unwrap_or("cycle").to_string(); let st = s.doc()?; if let Some(t) = st.selection.text.clone() { - let (a, b) = t.ordered(); - if a == b { - return ok(); + let (mut a, mut b) = t.ordered(); + let body = super::text::body_of(st, &t).cloned().unwrap_or_default(); + let collapsed = a == b; + if collapsed { + // Shift+F3 with a bare caret changes the word it is in and puts the caret at its start. + let (wa, wb) = ed::word_at(&body, a); + let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric); + if !(is_word && wa.1 <= a.1 && a.1 < wb.1) { + return ok(); + } + (a, b) = (wa, wb); } - let m = mode.clone(); - super::text::edit_text(s, move |body, _| { + let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() }; + super::text::edit_text(s, move |body, t| { for pi in a.0..=b.0 { let Some(para) = body.paragraphs.get_mut(pi) else { continue }; let n = para.char_len(); @@ -359,9 +375,23 @@ fn case(s: &mut Session, p: &Value) -> Result { pos += len; } } + if collapsed { + t.anchor = a; + t.caret = a; + } })?; return ok(); } + let mode = if mode == "cycle" { + let all: String = + st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::>().join( + " +", + ); + ed::next_case(&all).to_string() + } else { + mode + }; edit_shapes(s, &json!({}), "format.case", |sh| { if let Some(t) = sh.text.as_mut() { for para in &mut t.paragraphs { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 856f7ab0..1b2cb2d2 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -39,6 +39,16 @@ pub fn specs() -> Vec { commit ), cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret), + cmd!( + "text.delete", + "Delete Text", + [], + Some("Backspace"), + "{dir?: backward|forward|wordBackward|wordForward} — the word directions are Ctrl+Backspace/Delete: they work from the start of a selection without deleting it", + has_text, + delete + ), + cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret), cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select), cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all), cmd!(noundo "text.selectWord", "Select Word", [], None, "{at?: [p, c]}", has_text, select_word), @@ -187,7 +197,9 @@ pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, &body, &deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, ); - let (cx, top, bot) = l.caret(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 })?; + // After End on a wrapped line the caret is drawn at that line's end. + let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; + let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?; let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation); Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot))) } @@ -232,7 +244,7 @@ fn edit(s: &mut Session, p: &Value) -> Result { } s.select(|_, sel| { sel.shapes = vec![id]; - sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false }); + sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false, line_end: None }); })?; Ok(json!({"id": id, "at": [at.0, at.1]})) } @@ -503,13 +515,15 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result { let dir = dir.to_string(); edit_text(s, move |body, t| { let (a, b) = t.ordered(); - let at = if a != b { + let at = if dir == "wordBackward" || dir == "wordForward" { + // Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it + // (PowerPoint for Windows). + ed::delete_word(body, a, dir == "wordForward") + } else if a != b { ed::delete(body, a, b) } else { let other = match dir.as_str() { "forward" => ed::char_move(body, a, true), - "wordBackward" => ed::word_move(body, a, false), - "wordForward" => ed::word_move(body, a, true), _ => { // Backspace at the start of an indented/bulleted paragraph outdents first. if a.1 == 0 @@ -536,7 +550,16 @@ fn delete(s: &mut Session, p: &Value) -> Result { delete_dir(s, str_param(p, "dir").unwrap_or("backward")) } +/// Is paragraph `para` right-to-left (its lines run from the right)? +fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool { + match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) { + Some(line) => line.rtl, + None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false), + } +} + fn move_caret(s: &mut Session, p: &Value) -> Result { + use deckcraft_text::Affinity; let to = str_param(p, "to").unwrap_or("right").to_string(); let extend = bool_or(p, "extend", false); let st = s.doc()?; @@ -545,27 +568,44 @@ fn move_caret(s: &mut Session, p: &Value) -> Result { let layout = layout_for(st, &t); let lp = |c: Pos| deckcraft_text::Pos { para: c.0, ch: c.1 }; let caret = t.caret; + let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream }; + // Left and right are on screen: in a right-to-left paragraph left goes forward in the text. + let rtl = para_rtl(layout.as_ref(), &body, caret.0); + let to = match (to.as_str(), rtl) { + ("left", true) => "right", + ("right", true) => "left", + ("wordLeft", true) => "wordRight", + ("wordRight", true) => "wordLeft", + (x, _) => x, + } + .to_string(); let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 }; + let mut upstream = false; let new = match to.as_str() { "left" if t.is_range() && !extend => collapse_to(true), "right" if t.is_range() && !extend => collapse_to(false), "left" => ed::char_move(&body, caret, false), "right" => ed::char_move(&body, caret, true), - "wordLeft" => ed::word_move(&body, caret, false), - "wordRight" => ed::word_move(&body, caret, true), + // Ctrl+arrows with a selection go on from its start (left) or end (right). + "wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false), + "wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true), "paraStart" => (caret.0, 0), "paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)), + "paraUp" => ed::para_up(&body, caret), + "paraDown" => ed::para_down(&body, caret), "start" => (0, 0), "end" => ed::end(&body), "up" | "down" | "lineStart" | "lineEnd" => match &layout { Some(l) => { - let x = l.caret(lp(caret)).map(|c| c.0).unwrap_or(0.0); - let r = match to.as_str() { - "up" => l.vertical(lp(caret), false, x), - "down" => l.vertical(lp(caret), true, x), - "lineStart" => l.line_bounds(lp(caret)).0, - _ => l.line_bounds(lp(caret)).1, + let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0); + let (r, a) = match to.as_str() { + "up" => l.vertical_affine(lp(caret), aff, false, x), + "down" => l.vertical_affine(lp(caret), aff, true, x), + "lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream), + // End stays on its line: after a soft wrap the caret sits at the line's end. + _ => l.line_end_caret(lp(caret), aff), }; + upstream = a == Affinity::Upstream; (r.para, r.ch) } None => match to.as_str() { @@ -581,12 +621,13 @@ fn move_caret(s: &mut Session, p: &Value) -> Result { s.select(|_, sel| { if let Some(tt) = sel.text.as_mut() { tt.caret = new; + tt.line_end = if upstream { Some(new) } else { None }; if !extend { tt.anchor = new; } } })?; - Ok(json!({"caret": [new.0, new.1]})) + Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream})) } fn select(s: &mut Session, p: &Value) -> Result { @@ -633,11 +674,14 @@ fn select_para(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?; let at = pos_param(p, "at").unwrap_or(t.caret); - let n = body_of(st, &t).and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0); + let body = body_of(st, &t); + let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0); + // The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too). + let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) }; s.select(|_, sel| { if let Some(t) = sel.text.as_mut() { t.anchor = (at.0, 0); - t.caret = (at.0, n); + t.caret = end; } })?; ok() diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index cbfc705c..665cc22e 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -20,6 +20,30 @@ pub fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool { k.eq_ignore_ascii_case(key) && (has("cmd") || has("ctrl")) == mods.cmd && has("shift") == mods.shift && (has("alt") || has("option")) == mods.alt } +/// A key name in one spelling: lower case, `ArrowLeft` → `left`, and egui's names for +/// punctuation keys (`Period`, `CloseBracket`…) → the character, so `Cmd+Shift+.` matches the +/// key egui reports for Ctrl+Shift+> and `Cmd+]` the one for Ctrl+]. +pub fn key_token(key: &str) -> String { + let k = key.to_ascii_lowercase(); + let k = k.trim_start_matches("arrow"); + match k { + "period" | ">" => ".", + "comma" | "<" => ",", + "openbracket" | "{" => "[", + "closebracket" | "}" => "]", + "equals" => "=", + "minus" => "-", + "plus" => "+", + "slash" => "/", + "backslash" => "\\", + "semicolon" => ";", + "quote" => "'", + "backtick" => "`", + other => other, + } + .to_string() +} + impl Session { /// Handle one key press. `key` is a name (`Enter`, `Escape`, `Backspace`, `Delete`, `Tab`, /// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`). @@ -28,21 +52,17 @@ impl Session { self.end_composition(); let editing = self.active().is_some_and(|d| d.selection.text.is_some()); let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty()); - let k = key.to_ascii_lowercase(); - let k = k.trim_start_matches("arrow"); + let k = key_token(key); + let k = k.as_str(); if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") { return self.finish_freeform(false); } + if editing && let Some((id, params)) = self.text_key(k, mods) { + let r = self.execute(id, ¶ms)?; + return Ok(json!({"handled": true, "command": id, "result": r})); + } if editing && !mods.cmd { let r = match k { - "left" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordLeft" } else { "left" }, "extend": mods.shift}))), - "right" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordRight" } else { "right" }, "extend": mods.shift}))), - "up" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraStart" } else { "up" }, "extend": mods.shift}))), - "down" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraEnd" } else { "down" }, "extend": mods.shift}))), - "home" => Some(self.execute("text.move", &json!({"to": "lineStart", "extend": mods.shift}))), - "end" => Some(self.execute("text.move", &json!({"to": "lineEnd", "extend": mods.shift}))), - "backspace" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordBackward" } else { "backward" }}))), - "delete" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordForward" } else { "forward" }}))), "enter" | "return" => Some(self.execute("text.insert", &json!({"text": if mods.shift { "\u{b}" } else { "\n" }}))), "escape" | "esc" => Some(self.execute("text.exit", &json!({}))), "tab" => { @@ -117,6 +137,10 @@ impl Session { } // Shortcuts from the registry. let key_name = match k { + "left" => "Left", + "right" => "Right", + "up" => "Up", + "down" => "Down", "enter" | "return" => "Enter", "escape" | "esc" => "Escape", "delete" => "Delete", @@ -127,7 +151,7 @@ impl Session { "end" => "End", "tab" => "Tab", "space" | " " => "Space", - _ => key, + _ => k, }; let found = crate::command_specs().iter().find(|c| c.shortcut.is_some_and(|s| shortcut_matches(s, key_name, mods))).map(|c| c.id); if let Some(id) = found { @@ -137,6 +161,50 @@ impl Session { Ok(json!({"handled": false})) } + /// The text-editing command for a navigation or deletion key while editing text, by + /// platform: Ctrl (Windows, Linux) or Option (Mac) moves and deletes by word; Ctrl+Up/Down + /// moves by paragraph; Ctrl+Home/End (Cmd+Up/Down on the Mac) go to the ends of the text and + /// Cmd+Left/Right on the Mac to the ends of the line. Shift extends the selection. + fn text_key(&self, k: &str, mods: Mods) -> Option<(&'static str, Value)> { + let word = if self.mac_keys { mods.alt && !mods.cmd } else { mods.cmd && !mods.alt }; + let mac_cmd = self.mac_keys && mods.cmd && !mods.alt; + if mods.cmd && !mods.alt && !mods.shift { + // Ctrl+] / Ctrl+[ grow and shrink the font, like Ctrl+Shift+> / <. + match k { + "]" => return Some(("format.grow", json!({}))), + "[" => return Some(("format.shrink", json!({}))), + _ => {} + } + } + let plain = !mods.cmd && !mods.alt; + let to = match k { + "left" if word => "wordLeft", + "right" if word => "wordRight", + "left" if mac_cmd => "lineStart", + "right" if mac_cmd => "lineEnd", + "left" if plain => "left", + "right" if plain => "right", + "up" if mac_cmd => "start", + "down" if mac_cmd => "end", + "up" if word && self.mac_keys => "paraStart", + "down" if word && self.mac_keys => "paraEnd", + "up" if word => "paraUp", + "down" if word => "paraDown", + "up" if plain => "up", + "down" if plain => "down", + "home" if mods.cmd && !mods.alt => "start", + "end" if mods.cmd && !mods.alt => "end", + "home" if plain => "lineStart", + "end" if plain => "lineEnd", + "backspace" if word => return Some(("text.delete", json!({"dir": "wordBackward"}))), + "delete" if word => return Some(("text.delete", json!({"dir": "wordForward"}))), + "backspace" if plain => return Some(("text.delete", json!({"dir": "backward"}))), + "delete" if plain => return Some(("text.delete", json!({"dir": "forward"}))), + _ => return None, + }; + Some(("text.move", json!({"to": to, "extend": mods.shift}))) + } + /// Type text: into the text being edited, or (a shape selected) start editing it. pub fn type_text(&mut self, text: &str) -> Result { let st = self.doc()?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f6f15989..c2b56c7f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -68,6 +68,10 @@ pub struct TextSel { /// Editing the slide's speaker notes (shape is ignored). #[serde(default)] pub notes: bool, + /// The caret position that sits at the end of a wrapped line (after End) rather than at the + /// start of the next line. Ignored once the caret moves anywhere else. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line_end: Option<(usize, usize)>, } impl TextSel { @@ -77,6 +81,10 @@ impl TextSel { pub fn ordered(&self) -> ((usize, usize), (usize, usize)) { if self.anchor <= self.caret { (self.anchor, self.caret) } else { (self.caret, self.anchor) } } + /// Is the caret drawn at the end of the line before (upstream), not the start of the next? + pub fn at_line_end(&self) -> bool { + self.line_end == Some(self.caret) + } } /// Which shape tree is being edited: slides (normal), or a master/layout (Slide Master view). @@ -339,6 +347,10 @@ pub struct Session { pub media_status: std::collections::HashMap, /// Animated GIF pictures paused with `media.gifPlay` (the others play). pub gif_paused: std::collections::HashSet, + /// Text keys follow the Mac (Option+arrows move by word, Cmd+arrows to the line or text + /// ends) instead of Windows and Linux (Ctrl+arrows by word or paragraph). Defaults to the + /// platform DeckCraft was built for. + pub mac_keys: bool, } impl Default for Session { @@ -366,6 +378,7 @@ impl Session { recovery_dir: None, media_status: Default::default(), gif_paused: Default::default(), + mac_keys: cfg!(target_os = "macos"), } } /// A session with one new presentation open. @@ -492,6 +505,10 @@ impl Session { pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> { let st = self.doc_mut()?; let doc = st.doc.clone(); + // A caret placed anywhere starts downstream; only End puts it back at a line's end. + if let Some(t) = st.selection.text.as_mut() { + t.line_end = None; + } f(&doc, &mut st.selection); clamp_selection(st); st.revision += 1; @@ -536,3 +553,5 @@ pub(crate) fn push_undo(st: &mut DocState, e: HistoryEntry) { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_keys; diff --git a/crates/engine/src/tests_keys.rs b/crates/engine/src/tests_keys.rs new file mode 100644 index 00000000..926c4289 --- /dev/null +++ b/crates/engine/src/tests_keys.rs @@ -0,0 +1,404 @@ +//! Text-editing keys driven through `Session::key`, as the keyboard, control channel and MCP +//! send them. Every expectation is PowerPoint for Windows' observed behaviour unless it says Mac. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::tools::Mods; + +const NONE: Mods = Mods { shift: false, alt: false, cmd: false }; +const CTRL: Mods = Mods { shift: false, alt: false, cmd: true }; +const CTRL_SHIFT: Mods = Mods { shift: true, alt: false, cmd: true }; +const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false }; +const ALT: Mods = Mods { shift: false, alt: true, cmd: false }; + +type Pos = (usize, usize); + +/// A session editing a new text box holding `text` (Windows keys), caret at `at`. +fn editing(text: &str, at: Pos) -> (Session, u32) { + editing_in(text, at, [60.0, 60.0, 600.0, 300.0]) +} + +fn editing_in(text: &str, at: Pos, rect: [f64; 4]) -> (Session, u32) { + let mut s = Session::with_new(); + s.mac_keys = false; + s.prefs.autocorrect = false; + s.prefs.smart_quotes = false; + s.execute("insert.textBox", &json!({"rect": rect, "text": "x"})).unwrap(); + let id = s.doc().unwrap().selection.shapes[0].0; + s.execute("text.set", &json!({"id": id, "text": text})).unwrap(); + s.execute("text.edit", &json!({"id": id, "at": [at.0, at.1]})).unwrap(); + (s, id) +} + +fn sel(s: &Session) -> (Pos, Pos) { + let t = s.doc().unwrap().selection.text.clone().unwrap(); + (t.anchor, t.caret) +} + +fn caret(s: &Session) -> Pos { + sel(s).1 +} + +fn press(s: &mut Session, key: &str, mods: Mods) -> Value { + let r = s.key(key, mods).unwrap(); + assert_eq!(r["handled"], true, "{key} {mods:?} not handled"); + r +} + +fn text(s: &mut Session, id: u32) -> String { + s.execute("text.get", &json!({"id": id})).unwrap()["text"].as_str().unwrap().to_string() +} + +/// Press `key` until the caret stops moving; the carets visited (in the first paragraph). +fn walk(s: &mut Session, key: &str, mods: Mods) -> Vec { + let mut out = vec![caret(s).1]; + for _ in 0..200 { + let before = caret(s); + press(s, key, mods); + if caret(s) == before { + break; + } + out.push(caret(s).1); + } + out +} + +#[test] +fn ctrl_arrows_stop_where_powerpoint_does() { + let t = "Hello, world! Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end."; + let stops = vec![0, 5, 7, 12, 15, 18, 21, 25, 33, 38, 43, 44, 45, 46, 48, 49, 50, 52, 53, 54, 55, 56, 60, 61, 64, 66, 67, 68, 70, 73, 74]; + let (mut s, _) = editing(t, (0, 0)); + assert_eq!(walk(&mut s, "ArrowRight", CTRL), stops); + let mut back = stops.clone(); + back.reverse(); + assert_eq!(walk(&mut s, "ArrowLeft", CTRL), back); +} + +#[test] +fn ctrl_arrows_over_cjk_rtl_emoji_and_marks() { + let (mut s, _) = editing("日本語のテキスト abc 한국어", (0, 0)); + assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 1, 2, 3, 4, 5, 6, 7, 9, 13, 14, 15, 16]); + // Arabic and Hebrew words inside a left-to-right paragraph: in logical order. + let (mut s, _) = editing("Hello مرحبا بالعالم world שלום end", (0, 0)); + assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 6, 12, 20, 26, 31, 34]); + // An emoji ZWJ sequence and a combining mark are never split. + let (mut s, _) = editing("e\u{301}te 👨\u{200d}👩\u{200d}👧 x", (0, 0)); + assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 5, 11, 12]); + assert_eq!(walk(&mut s, "Left", CTRL), vec![12, 11, 5, 0]); +} + +#[test] +fn ctrl_arrows_cross_paragraphs_line_breaks_and_tabs() { + let (mut s, id) = editing("One two\nThree four\n\nFive", (0, 0)); + let mut seen = vec![caret(&s)]; + for _ in 0..9 { + press(&mut s, "Right", CTRL); + seen.push(caret(&s)); + } + assert_eq!(seen, vec![(0, 0), (0, 4), (0, 7), (1, 0), (1, 6), (1, 10), (2, 0), (3, 0), (3, 4), (3, 4)]); + // A line break and a tab are stops of their own. + s.execute("text.set", &json!({"id": id, "text": "x"})).unwrap(); + s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap(); + s.execute("text.selectAll", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "Five\u{b}six seven\tTab x"})).unwrap(); + s.execute("text.move", &json!({"to": "start"})).unwrap(); + assert_eq!(walk(&mut s, "Right", CTRL), vec![0, 4, 5, 9, 14, 15, 21, 22]); +} + +#[test] +fn ctrl_shift_arrows_extend_by_words() { + let (mut s, _) = editing("Hello world foo, bar\nnext para", (0, 2)); + let mut carets = vec![]; + for _ in 0..6 { + press(&mut s, "Right", CTRL_SHIFT); + carets.push(sel(&s)); + } + let a = (0, 2); + assert_eq!(carets.iter().map(|x| x.1).collect::>(), vec![(0, 6), (0, 13), (0, 16), (0, 18), (0, 21), (1, 0)]); + assert!(carets.iter().all(|x| x.0 == a)); + let mut back = vec![]; + for _ in 0..7 { + press(&mut s, "Left", CTRL_SHIFT); + back.push(caret(&s)); + } + assert_eq!(back, vec![(0, 21), (0, 18), (0, 16), (0, 13), (0, 6), (0, 0), (0, 0)]); + assert_eq!(sel(&s).0, a); + // From a caret inside a word going left: the word's start, then on. + let (mut s, _) = editing("Hello world foo, bar\nnext para", (1, 2)); + press(&mut s, "Left", CTRL_SHIFT); + press(&mut s, "Left", CTRL_SHIFT); + assert_eq!(sel(&s), ((1, 2), (0, 21))); +} + +#[test] +fn ctrl_arrows_leave_a_selection_from_its_ends() { + for (anchor, caret_at) in [((0, 2), (0, 7)), ((0, 7), (0, 2))] { + let (mut s, _) = editing("Hello world foo, bar", anchor); + s.execute("text.select", &json!({"anchor": [anchor.0, anchor.1], "caret": [caret_at.0, caret_at.1]})).unwrap(); + press(&mut s, "Right", CTRL); + assert_eq!(sel(&s), ((0, 13), (0, 13))); + s.execute("text.select", &json!({"anchor": [anchor.0, anchor.1], "caret": [caret_at.0, caret_at.1]})).unwrap(); + press(&mut s, "Left", CTRL); + assert_eq!(sel(&s), ((0, 0), (0, 0))); + } +} + +#[test] +fn ctrl_backspace_and_delete() { + let (mut s, id) = editing("Hello world foo, bar\nnext para", (0, 13)); + press(&mut s, "Backspace", CTRL); + assert_eq!(text(&mut s, id), "Hello foo, bar\nnext para"); + assert_eq!(caret(&s), (0, 6)); + press(&mut s, "Delete", CTRL); + assert_eq!(text(&mut s, id), "Hello , bar\nnext para".replace(" ,", ",")); + assert_eq!(caret(&s), (0, 5)); + // At a paragraph's ends they join paragraphs. + s.execute("text.move", &json!({"to": "paraEnd"})).unwrap(); + press(&mut s, "Delete", CTRL); + assert_eq!(text(&mut s, id), "Hello, barnext para"); + // A selection is not deleted: the word goes from its start (PowerPoint for Windows). + let (mut s, id) = editing("Hello world", (0, 2)); + s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 7]})).unwrap(); + press(&mut s, "Backspace", CTRL); + assert_eq!((text(&mut s, id), sel(&s)), ("llo world".into(), ((0, 0), (0, 0)))); + let (mut s, id) = editing("Hello world", (0, 7)); + s.execute("text.select", &json!({"anchor": [0, 7], "caret": [0, 2]})).unwrap(); + press(&mut s, "Delete", CTRL); + assert_eq!((text(&mut s, id), sel(&s)), ("He world".into(), ((0, 2), (0, 2)))); + // It is one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&mut s, id), "Hello world"); +} + +#[test] +fn ctrl_up_down_move_by_paragraph() { + let t = "First paragraph that is long enough to wrap in this box.\nSecond\n\nFourth para"; + let (mut s, _) = editing_in(t, (0, 5), [60.0, 60.0, 200.0, 300.0]); + let mut seen = vec![]; + for _ in 0..5 { + press(&mut s, "Down", CTRL); + seen.push(caret(&s)); + } + assert_eq!(seen, vec![(1, 0), (2, 0), (3, 0), (3, 11), (3, 11)]); + s.execute("text.move", &json!({"to": "end"})).unwrap(); + press(&mut s, "Left", NONE); + let mut seen = vec![]; + for _ in 0..5 { + press(&mut s, "Up", CTRL); + seen.push(caret(&s)); + } + assert_eq!(seen, vec![(3, 0), (2, 0), (1, 0), (0, 0), (0, 0)]); + // With Shift they extend; back past the anchor the selection flips. + s.execute("text.edit", &json!({"at": [0, 5]})).unwrap(); + press(&mut s, "Down", CTRL_SHIFT); + press(&mut s, "Down", CTRL_SHIFT); + assert_eq!(sel(&s), ((0, 5), (2, 0))); + press(&mut s, "Up", CTRL_SHIFT); + press(&mut s, "Up", CTRL_SHIFT); + assert_eq!(sel(&s), ((0, 5), (0, 0))); +} + +#[test] +fn home_end_and_ctrl_home_end() { + let t = "First paragraph that is long enough to wrap across several lines in this narrow box.\nSecond"; + let (mut s, _) = editing_in(t, (0, 0), [60.0, 60.0, 160.0, 300.0]); + let st = s.doc().unwrap(); + let ts = st.selection.text.clone().unwrap(); + let layout = crate::cmd::text::layout_for(st, &ts).unwrap(); + let lines: Vec<(usize, usize)> = layout.lines.iter().filter(|l| l.para == 0).map(|l| (l.start, l.end)).collect(); + assert!(lines.len() >= 3, "{lines:?}"); + let (l1, l2) = (lines[1], lines[2]); + // End goes past a wrapped line's trailing space, to where the next line starts, as in + // PowerPoint, and the caret stays drawn on the line it ended. + let next = lines[2].0; + s.execute("text.edit", &json!({"at": [0, l1.0 + 2]})).unwrap(); + press(&mut s, "Home", NONE); + assert_eq!(caret(&s), (0, l1.0)); + press(&mut s, "End", NONE); + assert_eq!(caret(&s), (0, next)); + assert!(s.doc().unwrap().selection.text.as_ref().unwrap().at_line_end()); + let drawn = |s: &Session| { + let st = s.doc().unwrap(); + let ts = st.selection.text.clone().unwrap(); + let l = crate::cmd::text::layout_for(st, &ts).unwrap(); + let aff = if ts.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; + l.caret_drawn(deckcraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }, aff).unwrap() + }; + let line1_top = layout.lines.iter().filter(|l| l.para == 0).nth(1).unwrap().top; + assert_eq!(drawn(&s).1, line1_top); + // End again stays on the line; Home goes back to its start, not the next line's. + press(&mut s, "End", NONE); + assert_eq!(caret(&s), (0, next)); + press(&mut s, "Home", SHIFT); + assert_eq!(sel(&s), ((0, next), (0, l1.0))); + press(&mut s, "End", SHIFT); + assert_eq!(sel(&s), ((0, next), (0, next))); + assert_eq!(drawn(&s).1, line1_top); + // Down from a line's end lands on the next line, not two lines down. + press(&mut s, "Down", NONE); + let c = caret(&s); + assert!(c.1 > l2.0 && c.1 <= l2.1, "{c:?} not on line {l2:?}"); + // The caret is drawn at the end of the line it belongs to. + s.execute("text.move", &json!({"to": "lineEnd"})).unwrap(); + let ts = s.doc().unwrap().selection.text.clone().unwrap(); + assert!(ts.at_line_end()); + // Ctrl+Home / Ctrl+End, with Shift extending. + press(&mut s, "End", CTRL); + assert_eq!(caret(&s), (1, 6)); + press(&mut s, "Home", CTRL_SHIFT); + assert_eq!(sel(&s), ((1, 6), (0, 0))); + press(&mut s, "Home", CTRL); + press(&mut s, "End", CTRL_SHIFT); + assert_eq!(sel(&s), ((0, 0), (1, 6))); + // A caret placed anywhere else is drawn at the start of its line again. + s.execute("text.select", &json!({"anchor": [0, next], "caret": [0, next]})).unwrap(); + assert!(!s.doc().unwrap().selection.text.as_ref().unwrap().at_line_end()); + assert!(drawn(&s).1 > line1_top); +} + +#[test] +fn right_to_left_paragraphs_swap_left_and_right() { + let (mut s, _) = editing("مرحبا بالعالم hello world", (0, 0)); + s.execute("format.rtl", &json!({"on": true})).unwrap(); + // Ctrl+Left goes forward in a right-to-left paragraph, Ctrl+Right back. + assert_eq!(walk(&mut s, "Left", CTRL), vec![0, 6, 14, 20, 25]); + assert_eq!(walk(&mut s, "Right", CTRL), vec![25, 20, 14, 6, 0]); + press(&mut s, "Left", NONE); + assert_eq!(caret(&s), (0, 1)); + press(&mut s, "Left", CTRL_SHIFT); + assert_eq!(sel(&s), ((0, 1), (0, 6))); +} + +#[test] +fn mac_keys() { + let (mut s, id) = editing("Hello world foo\nnext", (0, 8)); + s.mac_keys = true; + press(&mut s, "Right", ALT); + assert_eq!(caret(&s), (0, 12)); + press(&mut s, "Left", ALT); + assert_eq!(caret(&s), (0, 6)); + press(&mut s, "Right", CTRL); + assert_eq!(caret(&s), (0, 15)); + press(&mut s, "Left", CTRL); + assert_eq!(caret(&s), (0, 0)); + press(&mut s, "Down", CTRL); + assert_eq!(caret(&s), (1, 4)); + press(&mut s, "Up", CTRL); + assert_eq!(caret(&s), (0, 0)); + s.execute("text.edit", &json!({"id": id, "at": [0, 11]})).unwrap(); + press(&mut s, "Backspace", ALT); + assert_eq!(text(&mut s, id), "Hello foo\nnext"); + // Ctrl+arrows aren't word moves on the Mac, nor Alt+arrows on Windows. + s.mac_keys = false; + let before = caret(&s); + let r = s.key("Right", ALT).unwrap(); + assert_eq!((r["handled"].as_bool(), caret(&s)), (Some(false), before)); +} + +#[test] +fn font_size_keys() { + let (mut s, _) = editing("hello wOrld. foo bar", (0, 0)); + s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap(); + let size = |s: &mut Session| s.execute("format.state", &json!({})).unwrap()["size"].as_f64(); + let base = size(&mut s).unwrap_or(18.0); + // egui names the keys `Period`, `Comma`, `CloseBracket`, `OpenBracket`. + press(&mut s, "Period", CTRL_SHIFT); + let up = size(&mut s).unwrap(); + assert!(up > base); + press(&mut s, "Comma", CTRL_SHIFT); + assert_eq!(size(&mut s), Some(base)); + press(&mut s, "CloseBracket", CTRL); + assert_eq!(size(&mut s), Some(up)); + press(&mut s, "OpenBracket", CTRL); + assert_eq!(size(&mut s), Some(base)); + press(&mut s, ">", CTRL_SHIFT); + assert_eq!(size(&mut s), Some(up)); +} + +#[test] +fn bold_italic_underline_and_clear_on_a_caret_in_a_word() { + let (mut s, id) = editing("hello wOrld. foo bar", (0, 8)); + let r = press(&mut s, "b", CTRL); + assert_eq!(r["command"], "format.bold"); + let body = |s: &Session| s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap(); + let runs: Vec<(String, Option)> = body(&s).paragraphs[0].runs.iter().map(|r| (r.text.clone(), r.props.bold)).collect(); + assert_eq!(runs.iter().find(|r| r.1 == Some(true)).map(|r| r.0.as_str()), Some("wOrld")); + // At a word boundary nothing visible changes. + s.execute("text.edit", &json!({"id": id, "at": [0, 5]})).unwrap(); + press(&mut s, "i", CTRL); + assert!(body(&s).paragraphs[0].runs.iter().all(|r| r.props.italic != Some(true))); + // Ctrl+Space clears the word's formatting. + s.execute("text.edit", &json!({"id": id, "at": [0, 8]})).unwrap(); + press(&mut s, "Space", CTRL); + assert!(body(&s).paragraphs[0].runs.iter().all(|r| r.props.bold != Some(true))); + press(&mut s, "u", CTRL); + let under: String = body(&s).paragraphs[0].runs.iter().filter(|r| r.props.underline.is_some()).map(|r| r.text.clone()).collect(); + assert_eq!(under, "wOrld"); +} + +#[test] +fn alignment_keys() { + let (mut s, _) = editing("one\ntwo", (0, 1)); + for (k, want) in [("e", "ctr"), ("r", "r"), ("j", "just"), ("l", "l")] { + press(&mut s, k, CTRL); + let st = s.execute("format.state", &json!({})).unwrap(); + assert_eq!(st["align"], want, "Ctrl+{k}"); + } +} + +#[test] +fn shift_f3_cycles_case() { + let (mut s, id) = editing("hello world", (0, 0)); + s.execute("text.selectAll", &json!({})).unwrap(); + let mut seen = vec![]; + for _ in 0..3 { + press(&mut s, "F3", SHIFT); + seen.push(text(&mut s, id)); + } + assert_eq!(seen, vec!["Hello World", "HELLO WORLD", "hello world"]); + s.execute("text.set", &json!({"id": id, "text": "hello wOrld. foo"})).unwrap(); + s.execute("text.edit", &json!({"id": id, "at": [0, 8]})).unwrap(); + press(&mut s, "F3", SHIFT); + assert_eq!(text(&mut s, id), "hello WORLD. foo"); + assert_eq!(sel(&s), ((0, 6), (0, 6))); + press(&mut s, "F3", SHIFT); + assert_eq!(text(&mut s, id), "hello world. foo"); +} + +#[test] +fn shift_enter_and_word_and_paragraph_selection() { + let (mut s, id) = editing("Hello world, foo bar\nnext", (0, 7)); + s.execute("text.selectWord", &json!({})).unwrap(); + assert_eq!(sel(&s), ((0, 6), (0, 11))); + s.execute("text.selectWord", &json!({"at": [0, 12]})).unwrap(); + assert_eq!(sel(&s), ((0, 11), (0, 12))); + // Triple-click: the paragraph and its mark. + s.execute("text.selectParagraph", &json!({"at": [0, 3]})).unwrap(); + assert_eq!(sel(&s), ((0, 0), (1, 0))); + s.execute("text.selectParagraph", &json!({"at": [1, 1]})).unwrap(); + assert_eq!(sel(&s), ((1, 0), (1, 4))); + s.execute("text.edit", &json!({"id": id, "at": [1, 2]})).unwrap(); + press(&mut s, "Enter", SHIFT); + let b = s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap(); + assert_eq!(b.paragraphs.len(), 2); + assert_eq!(b.paragraphs[1].text(), "ne\u{b}xt"); +} + +#[test] +fn keys_never_panic_on_any_text() { + let texts = ["", " ", "\u{b}", "\t\t", "👨\u{200d}👩\u{200d}👧", "e\u{301}", "日本", "مرحبا", "a\n\nb", " x \n "]; + let keys = ["Left", "Right", "Up", "Down", "Home", "End", "Backspace", "Delete"]; + for t in texts { + for k in keys { + for m in [CTRL, CTRL_SHIFT, NONE, SHIFT, ALT] { + for at in [(0, 0), (0, 1), (5, 5)] { + let (mut s, _) = editing(t, at); + for _ in 0..3 { + let _ = s.key(k, m).unwrap(); + } + } + } + } + } +} diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index cb6b4f18..2894f000 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -14,3 +14,4 @@ deckcraft-color = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +unicode-segmentation = { workspace = true } diff --git a/crates/model/src/edit.rs b/crates/model/src/edit.rs index dcdc5089..af082b89 100644 --- a/crates/model/src/edit.rs +++ b/crates/model/src/edit.rs @@ -2,6 +2,7 @@ //! with `\n` splits paragraphs; formatting a range splits runs at its ends. use crate::text::{Paragraph, Run, RunKind, RunProps, TextBody}; +use crate::words; /// (paragraph, char offset in the paragraph). pub type Pos = (usize, usize); @@ -275,65 +276,109 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos { caret } -fn is_word(c: char) -> bool { - c.is_alphanumeric() || c == '_' || c == '\'' +fn para_text(body: &TextBody, pi: usize) -> String { + body.paragraphs.get(pi).map(Paragraph::text).unwrap_or_default() } -/// Word boundaries around `pos` (double-click selection). +/// The word a double-click selects around `pos` (see [`words::word_at`]): the word without its +/// trailing spaces, as PowerPoint selects it. pub fn word_at(body: &TextBody, pos: Pos) -> (Pos, Pos) { let (pi, ch) = clamp(body, pos); - let t: Vec = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default(); - if t.is_empty() { - return ((pi, 0), (pi, 0)); - } - let at = ch.min(t.len().saturating_sub(1)); - let w = t.get(at).copied().is_some_and(is_word); - let mut s = at; - while s > 0 && t.get(s - 1).copied().is_some_and(|c| is_word(c) == w && !c.is_whitespace() || (w && is_word(c))) { - s -= 1; - } - let mut e = at; - while e < t.len() && t.get(e).copied().is_some_and(|c| is_word(c) == w && (w || !c.is_whitespace())) { - e += 1; - } - // Include trailing spaces after a word. - while w && e < t.len() && t.get(e) == Some(&' ') { - e += 1; - } - ((pi, s), (pi, e.max(s))) + let (s, e) = words::word_at(¶_text(body, pi), ch); + ((pi, s), (pi, e)) } -/// Next/previous word start (Option/Ctrl + arrows). +/// Next/previous word stop (Ctrl+Right/Left on Windows, Option+arrows on the Mac). At the end of +/// a paragraph Ctrl+Right goes to the start of the next one; at its start Ctrl+Left goes to the +/// end of the previous one. pub fn word_move(body: &TextBody, pos: Pos, forward: bool) -> Pos { let (pi, ch) = clamp(body, pos); - let t: Vec = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default(); + let t = para_text(body, pi); if forward { - if ch >= t.len() { - return if pi + 1 < body.paragraphs.len() { (pi + 1, 0) } else { (pi, ch) }; + match words::next_stop(&t, ch) { + Some(s) => (pi, s), + None if pi + 1 < body.paragraphs.len() => (pi + 1, 0), + None => (pi, ch), } - let mut i = ch; - while i < t.len() && t.get(i).copied().is_some_and(is_word) { - i += 1; - } - while i < t.len() && !t.get(i).copied().is_some_and(is_word) { - i += 1; - } - (pi, i) } else { - if ch == 0 { - return if pi > 0 { (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)) } else { (0, 0) }; + match words::prev_stop(&t, ch) { + Some(s) => (pi, s), + None if pi > 0 => (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)), + None => (pi, 0), } - let mut i = ch; - while i > 0 && !t.get(i - 1).copied().is_some_and(is_word) { - i -= 1; - } - while i > 0 && t.get(i - 1).copied().is_some_and(is_word) { - i -= 1; - } - (pi, i) } } +/// Ctrl+Up: the start of the paragraph, or of the previous one when already there. +pub fn para_up(body: &TextBody, pos: Pos) -> Pos { + let (pi, ch) = clamp(body, pos); + if ch > 0 { (pi, 0) } else { (pi.saturating_sub(1), 0) } +} + +/// Ctrl+Down: the start of the next paragraph, or the end of the last one. +pub fn para_down(body: &TextBody, pos: Pos) -> Pos { + let (pi, _) = clamp(body, pos); + if pi + 1 < body.paragraphs.len() { (pi + 1, 0) } else { (pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0)) } +} + +/// Delete a word from `pos` as Ctrl+Backspace (`forward == false`) or Ctrl+Delete does in +/// PowerPoint; returns the caret. +/// +/// Ctrl+Backspace deletes back to the previous word stop (at a paragraph's start it joins the +/// paragraph to the one before). Ctrl+Delete deletes the rest of the word, or the spaces after a +/// word and the next word, or up to the next stop (see [`words::delete_forward_end`]), then +/// tidies the spacing as PowerPoint's smart cut does: spaces left before closing punctuation or +/// at the end of the paragraph after a word go, and sentence punctuation left against a word +/// gets a space. At a paragraph's end it joins the next paragraph. +pub fn delete_word(body: &mut TextBody, pos: Pos, forward: bool) -> Pos { + if body.paragraphs.is_empty() { + return (0, 0); + } + let (pi, ch) = clamp(body, pos); + let t = para_text(body, pi); + if !forward { + let to = match words::prev_stop(&t, ch) { + Some(s) => (pi, s), + None => char_move(body, (pi, ch), false), + }; + return delete(body, to, (pi, ch)); + } + let Some(e) = words::delete_forward_end(&t, ch) else { + let to = char_move(body, (pi, ch), true); + return delete(body, (pi, ch), to); + }; + let at = delete(body, (pi, ch), (pi, e)); + smart_space(body, at) +} + +/// PowerPoint's smart-cut spacing after a word was deleted at `at`; returns the caret. +fn smart_space(body: &mut TextBody, at: Pos) -> Pos { + let (pi, s) = at; + let chars: Vec = para_text(body, pi).chars().collect(); + let after = chars.get(s).copied(); + let before = s.checked_sub(1).and_then(|i| chars.get(i)).copied(); + if after.is_none_or(words::is_closing_punct) && before.is_some_and(|c| c == ' ') { + let mut w = s; + while w > 0 && chars.get(w - 1) == Some(&' ') { + w -= 1; + } + if w > 0 && chars.get(w - 1).is_some_and(|c| c.is_alphanumeric()) { + return delete(body, (pi, w), (pi, s)); + } + return at; + } + if let (Some(b), Some(a)) = (before, after) + && words::is_sentence_punct(b) + && a.is_alphanumeric() + { + let digits = s >= 2 && chars.get(s - 2).is_some_and(|c| c.is_numeric()) && a.is_numeric(); + if !digits { + return insert(body, at, " ", None); + } + } + at +} + /// One character left/right across paragraph boundaries. pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos { let (pi, ch) = clamp(body, pos); @@ -361,6 +406,18 @@ pub fn end(body: &TextBody) -> Pos { (pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0)) } +/// The case Shift+F3 changes `s` to next, as PowerPoint cycles it: lowercase → Capitalize Each +/// Word → UPPERCASE → lowercase; any other mix goes to UPPERCASE first. +pub fn next_case(s: &str) -> &'static str { + if s == s.to_lowercase() && s.chars().any(char::is_alphabetic) { + "title" + } else if s == s.to_uppercase() && s.chars().any(char::is_alphabetic) { + "lower" + } else { + "upper" + } +} + /// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`. pub fn change_case(s: &str, mode: &str) -> String { match mode { @@ -456,7 +513,7 @@ mod tests { #[test] fn words_and_moves() { let b = body("Hello brave world"); - assert_eq!(word_at(&b, (0, 7)), ((0, 6), (0, 12))); + assert_eq!(word_at(&b, (0, 7)), ((0, 6), (0, 11))); assert_eq!(word_move(&b, (0, 0), true), (0, 6)); assert_eq!(word_move(&b, (0, 8), false), (0, 6)); assert_eq!(char_move(&b, (0, 17), true), (0, 17)); @@ -472,4 +529,150 @@ mod tests { assert_eq!(b.paragraphs[0].char_len(), 3); assert_eq!(b.paragraphs.len(), 1); } + + /// Ctrl+Backspace / Ctrl+Delete from `at`: the text and caret after (PowerPoint's results). + fn del_word(text: &str, at: Pos, forward: bool) -> (String, Pos) { + let mut b = body(text); + let c = delete_word(&mut b, at, forward); + (b.text(), c) + } + + #[test] + fn ctrl_backspace_like_powerpoint() { + let t = "ab cd...ef gh"; + let want = [ + ("ab cd...ef gh", 0), + ("b cd...ef gh", 0), + (" cd...ef gh", 0), + (" cd...ef gh", 0), + (" cd...ef gh", 0), + ("cd...ef gh", 0), + ("ab d...ef gh", 5), + ("ab ...ef gh", 5), + ("ab cd..ef gh", 7), + ("ab cd.ef gh", 7), + ("ab cdef gh", 7), + ("ab cd...f gh", 10), + ("ab cd... gh", 10), + ("ab cd...gh", 10), + ("ab cd...ef h", 13), + ("ab cd...ef ", 13), + ]; + for (c, (txt, caret)) in want.iter().enumerate() { + assert_eq!(del_word(t, (0, c), false), (txt.to_string(), (0, *caret)), "caret {c}"); + } + let w = "Hello world foo, bar\nnext para"; + assert_eq!(del_word(w, (0, 12), false), ("Hello foo, bar\nnext para".into(), (0, 6))); + assert_eq!(del_word(w, (0, 17), false), ("Hello world foo bar\nnext para".into(), (0, 16))); + // At a paragraph's start it joins the paragraph before. + assert_eq!(del_word(w, (1, 0), false), ("Hello world foo, barnext para".into(), (0, 21))); + assert_eq!(del_word("ab\n cd", (1, 2), false), ("ab\ncd".into(), (1, 0))); + // A line break is a word of its own. + let mut b = body("ab"); + insert(&mut b, (0, 2), "\u{b}cd", None); + assert_eq!(delete_word(&mut b, (0, 3), false), (0, 2)); + assert_eq!(b.text(), "abcd"); + let mut b = body("ab"); + insert(&mut b, (0, 2), "\u{b}cd", None); + assert_eq!(delete_word(&mut b, (0, 2), true), (0, 2)); + assert_eq!(b.text(), "abcd"); + assert_eq!(del_word("x", (0, 0), false), ("x".into(), (0, 0))); + // Emoji sequences and combining marks are never split. + assert_eq!(del_word("a 👨\u{200d}👩\u{200d}👧", (0, 7), false), ("a ".into(), (0, 2))); + assert_eq!(del_word("ce\u{301}te x", (0, 5), false), (" x".into(), (0, 0))); + assert_eq!(del_word("ce\u{301}te x", (0, 6), false), ("x".into(), (0, 0))); + // Each CJK character is a word. + assert_eq!(del_word("日本語", (0, 3), false), ("日本".into(), (0, 2))); + } + + #[test] + fn ctrl_delete_like_powerpoint() { + let t = "ab cd...ef gh"; + let want = [ + ("cd...ef gh", 0), + ("a cd...ef gh", 1), + ("ab...ef gh", 2), + ("ab cd...ef gh", 3), + ("ab cd...ef gh", 4), + ("ab...ef gh", 2), + ("ab c...ef gh", 6), + ("ab cdef gh", 7), + ("ab cd. ef gh", 9), + ("ab cd.. ef gh", 10), + ("ab cd... gh", 11), + ("ab cd...e gh", 11), + ("ab cd...ef", 12), + ("ab cd...ef", 12), + ("ab cd...ef g", 14), + ("ab cd...ef gh", 15), + ]; + for (c, (txt, caret)) in want.iter().enumerate() { + assert_eq!(del_word(t, (0, c), true), (txt.to_string(), (0, *caret)), "caret {c}"); + } + let w = "Hello world foo, bar\nnext para"; + let want = [ + (0, "world foo, bar\nnext para", 0), + (2, "He world foo, bar\nnext para", 2), + (5, "Hello foo, bar\nnext para", 5), + (6, "Hello foo, bar\nnext para", 6), + (8, "Hello wo foo, bar\nnext para", 8), + (11, "Hello world, bar\nnext para", 11), + (12, "Hello world foo, bar\nnext para", 12), + (16, "Hello world foobar\nnext para", 16), + (17, "Hello world foo,\nnext para", 17), + (18, "Hello world foo, \nnext para", 18), + (21, "Hello world foo, barnext para", 21), + ]; + for (c, txt, caret) in want { + assert_eq!(del_word(w, (0, c), true), (txt.to_string(), (0, caret)), "caret {c}"); + } + assert_eq!(del_word(w, (1, 0), true), ("Hello world foo, bar\npara".into(), (1, 0))); + assert_eq!(del_word(w, (1, 2), true), ("Hello world foo, bar\nne para".into(), (1, 2))); + assert_eq!(del_word("ab \ncd", (0, 2), true), ("ab\ncd".into(), (0, 2))); + assert_eq!(del_word("ab \ncd", (0, 3), true), ("ab\ncd".into(), (0, 2))); + assert_eq!(del_word("ab\t cd", (0, 2), true), ("ab cd".into(), (0, 2))); + assert_eq!(del_word("ab cd(x) ef", (0, 3), true), ("ab (x) ef".into(), (0, 3))); + assert_eq!(del_word("ab cd, ef", (0, 3), true), ("ab, ef".into(), (0, 2))); + assert_eq!(del_word("ab. (cd) ef", (0, 5), true), ("ab. () ef".into(), (0, 5))); + assert_eq!(del_word("ab \"cd\" ef", (0, 4), true), ("ab \"\" ef".into(), (0, 4))); + assert_eq!(del_word("ab, cd", (0, 4), true), ("ab, ".into(), (0, 4))); + // Right-to-left text deletes in logical order like any other. + assert_eq!(del_word("مرحبا بالعالم hello", (0, 6), true), ("مرحبا hello".into(), (0, 6))); + assert_eq!(del_word("مرحبا بالعالم hello", (0, 8), false), ("مرحبا لعالم hello".into(), (0, 6))); + // Numbers keep their separators: no space is pushed into "3.14". + assert_eq!(del_word("3.x 14", (0, 2), true), ("3.14".into(), (0, 2))); + // Hostile positions clamp. + assert_eq!(del_word("ab", (9, 9), true).0, "ab"); + assert_eq!(del_word("", (0, 0), false).0, ""); + let mut empty = TextBody::default(); + assert_eq!(delete_word(&mut empty, (3, 3), true), (0, 0)); + } + + #[test] + fn paragraph_moves() { + let b = body("one two\nthree\n\nfour"); + assert_eq!(para_down(&b, (0, 2)), (1, 0)); + assert_eq!(para_down(&b, (1, 0)), (2, 0)); + assert_eq!(para_down(&b, (3, 1)), (3, 4)); + assert_eq!(para_up(&b, (3, 2)), (3, 0)); + assert_eq!(para_up(&b, (3, 0)), (2, 0)); + assert_eq!(para_up(&b, (0, 0)), (0, 0)); + assert_eq!(word_move(&b, (0, 4), true), (0, 7)); + assert_eq!(word_move(&b, (0, 7), true), (1, 0)); + assert_eq!(word_move(&b, (1, 0), false), (0, 7)); + assert_eq!(word_move(&b, (2, 0), true), (3, 0)); + assert_eq!(word_move(&b, (3, 4), true), (3, 4)); + assert_eq!(word_move(&b, (0, 0), false), (0, 0)); + } + + #[test] + fn case_cycle() { + assert_eq!(next_case("hello world"), "title"); + assert_eq!(next_case("Hello World"), "upper"); + assert_eq!(next_case("HELLO WORLD"), "lower"); + assert_eq!(next_case("Hello world"), "upper"); + assert_eq!(next_case("hELLO wORLD"), "upper"); + assert_eq!(next_case("hello. world is HERE"), "upper"); + assert_eq!(next_case("123"), "upper"); + } } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index c3f0f785..c5e307d9 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -16,6 +16,7 @@ pub mod style; pub mod table; pub mod text; pub mod theme; +pub mod words; use std::sync::Arc; diff --git a/crates/model/src/words.rs b/crates/model/src/words.rs new file mode 100644 index 00000000..54b23659 --- /dev/null +++ b/crates/model/src/words.rs @@ -0,0 +1,420 @@ +//! Word boundaries in a paragraph, as PowerPoint's Ctrl+arrow, Ctrl+Backspace/Delete and +//! double-click see them (observed black-box on PowerPoint for Windows, see +//! `docs/ui-parity.md`, Text editing keys). +//! +//! A paragraph splits into *tokens*. Each token is a run of one class of grapheme clusters plus +//! the spaces that follow it: +//! +//! - **word**: letters, digits and combining marks. An apostrophe (`'` or `’`) joins a word when a +//! letter or digit follows it (`it's`, `'tis`, but `dogs'` ends before it); a hyphen joins when +//! a letter or digit comes before it (`a-b`, `1-2`, `a-`); `.`, `,` and `:` join between digits +//! (`3.14`, `1,000`, `3:45`, but `e.g.` is five tokens). +//! - **punctuation**: everything else that is not a symbol; a run of it is one token (`...`, +//! `?!`, `),`, `。。`). `_`, `@`, `/`, `#`, quotes and brackets are punctuation. +//! - **symbol**: emoji and pictographs (a run of them is one token, `👍👍`), `©`, `®`, `™`. +//! - **ideograph**: each CJK ideograph, kana or Hangul syllable is a token of its own. +//! - **tab** and **line break**: each is a token of its own, and spaces after one form their own +//! token instead of joining it. +//! - **space**: U+0020, no-break space, ideographic space and the other fixed-width spaces. Spaces +//! join the token before them; spaces at the start of a paragraph (or after a tab or line break) +//! are a token of their own. +//! +//! Positions are character (Unicode scalar) offsets in the paragraph text, where a line break is +//! one `\u{b}` character. A token never splits a grapheme cluster, so the caret never lands +//! inside an emoji sequence or between a letter and its combining marks. + +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Class { + Space, + Tab, + Break, + Word, + Ideo, + Punct, + Symbol, +} + +/// One grapheme cluster: its first character offset and class. +#[derive(Clone, Copy, Debug)] +struct Cluster { + start: usize, + class: Class, +} + +/// A space for word movement: absorbed into the token before it. +pub fn is_space(c: char) -> bool { + matches!(c, ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}'..='\u{200a}' | '\u{202f}' | '\u{205f}' | '\u{3000}') +} + +/// CJK ideographs, kana and Hangul: every one is a word of its own. +fn is_ideo(c: char) -> bool { + matches!(c, + '\u{1100}'..='\u{11ff}' + | '\u{2e80}'..='\u{2fdf}' + | '\u{3005}'..='\u{3007}' + | '\u{3021}'..='\u{3029}' + | '\u{3031}'..='\u{3035}' + | '\u{3038}'..='\u{303c}' + | '\u{3040}'..='\u{309f}' + | '\u{30a0}'..='\u{30ff}' + | '\u{3100}'..='\u{318f}' + | '\u{31a0}'..='\u{31ff}' + | '\u{3400}'..='\u{4dbf}' + | '\u{4e00}'..='\u{9fff}' + | '\u{a960}'..='\u{a97f}' + | '\u{ac00}'..='\u{d7ff}' + | '\u{f900}'..='\u{faff}' + | '\u{ff66}'..='\u{ffdc}' + | '\u{20000}'..='\u{3134f}') +} + +/// Emoji, pictographs and the common letter-like symbols. +fn is_symbol(c: char) -> bool { + matches!(c, + '\u{a9}' | '\u{ae}' | '\u{2122}' + | '\u{2190}'..='\u{21ff}' + | '\u{2300}'..='\u{23ff}' + | '\u{25a0}'..='\u{27bf}' + | '\u{2b00}'..='\u{2bff}' + | '\u{1f000}'..='\u{1faff}') +} + +fn base_class(c: char) -> Class { + match c { + '\u{b}' | '\n' | '\r' | '\u{2028}' | '\u{2029}' => Class::Break, + '\t' => Class::Tab, + c if is_space(c) => Class::Space, + c if is_ideo(c) => Class::Ideo, + c if c.is_alphanumeric() => Class::Word, + c if is_symbol(c) => Class::Symbol, + // A combining mark with no base (or any other mark) stays with letters. + '\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}' => Class::Word, + _ => Class::Punct, + } +} + +fn clusters(text: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut firsts: Vec = Vec::new(); + let mut pos = 0; + for g in text.graphemes(true) { + let n = g.chars().count(); + let first = g.chars().next().unwrap_or(' '); + // "\r\n" is one grapheme; any other cluster takes the class of its first character. + out.push(Cluster { start: pos, class: base_class(first) }); + firsts.push(first); + pos += n; + } + // Context: apostrophes, hyphens and number separators that belong to a word. + let classes: Vec = out.iter().map(|c| c.class).collect(); + for (i, cl) in out.iter_mut().enumerate() { + let Some(&c) = firsts.get(i) else { continue }; + let prev = i.checked_sub(1).and_then(|j| firsts.get(j)).copied(); + let next = firsts.get(i + 1).copied(); + let joins = match c { + '\'' | '\u{2019}' => next.is_some_and(char::is_alphanumeric), + '-' => prev.is_some_and(char::is_alphanumeric), + '.' | ',' | ':' => prev.is_some_and(char::is_numeric) && next.is_some_and(char::is_numeric), + _ => false, + }; + if joins && classes.get(i) == Some(&Class::Punct) { + cl.class = Class::Word; + } + } + out +} + +/// Does a token start at cluster `cur` (after `prev`)? +fn starts_token(prev: Class, cur: Class) -> bool { + if cur == Class::Space { + // Spaces join the token before them, except after a tab or a line break. + return matches!(prev, Class::Tab | Class::Break); + } + prev == Class::Space || prev != cur || matches!(cur, Class::Ideo | Class::Tab | Class::Break) +} + +/// The word stops of a paragraph's text in ascending order: 0, the start of every token, and the +/// end of the text. Ctrl+Right goes to the next one. +pub fn stops(text: &str) -> Vec { + let cs = clusters(text); + let mut out = vec![0]; + for w in cs.windows(2) { + if let [a, b] = w + && starts_token(a.class, b.class) + { + out.push(b.start); + } + } + let n = text.chars().count(); + if out.last() != Some(&n) { + out.push(n); + } + out +} + +/// The stops Ctrl+Left uses. PowerPoint's backward scan differs from the forward one in two +/// observed details: a no-break or other non-ASCII space is a stop of its own (unless an ASCII +/// space follows it), and a symbol followed by punctuation (`👍?`) is one token. +pub fn back_stops(text: &str) -> Vec { + let cs = clusters(text); + let chars: Vec = text.chars().collect(); + let mut out = vec![0]; + for w in cs.windows(2) { + if let [a, b] = w { + let symbol_punct = matches!((a.class, b.class), (Class::Symbol, Class::Punct) | (Class::Punct, Class::Symbol)); + if starts_token(a.class, b.class) && !symbol_punct { + out.push(b.start); + } + } + } + for (i, c) in chars.iter().enumerate() { + if *c != ' ' && is_space(*c) && chars.get(i + 1) != Some(&' ') && i > 0 { + out.push(i); + } + } + out.sort_unstable(); + out.dedup(); + let n = chars.len(); + if out.last() != Some(&n) { + out.push(n); + } + out +} + +/// The next stop after `ch` (Ctrl+Right), or `None` at the end of the text. +pub fn next_stop(text: &str, ch: usize) -> Option { + stops(text).into_iter().find(|s| *s > ch) +} + +/// The previous stop before `ch` (Ctrl+Left), or `None` at the start. +pub fn prev_stop(text: &str, ch: usize) -> Option { + back_stops(text).into_iter().rev().find(|s| *s < ch) +} + +/// Is the character at `i` a space (for word purposes)? +fn space_at(chars: &[char], i: usize) -> bool { + chars.get(i).is_some_and(|c| is_space(*c)) +} + +/// The end of the token's text (before its trailing spaces) for the token containing `ch`. +fn content_end(text: &str, ch: usize) -> usize { + let chars: Vec = text.chars().collect(); + let next = next_stop(text, ch).unwrap_or(chars.len()); + let mut e = ch; + while e < next && !space_at(&chars, e) { + e += 1; + } + // A token made only of spaces (leading spaces) ends at the next stop. + if e == ch && space_at(&chars, ch) { next } else { e } +} + +/// The word a double-click at `ch` selects: the token under the caret without its trailing +/// spaces; on a space just after a token, that token; in a run of spaces, the spaces. +pub fn word_at(text: &str, ch: usize) -> (usize, usize) { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + if n == 0 { + return (0, 0); + } + let ch = ch.min(n); + let on_space = ch >= n || space_at(&chars, ch); + let after_token = ch > 0 && !space_at(&chars, ch - 1); + let at = if on_space && after_token { ch - 1 } else { ch.min(n - 1) }; + if space_at(&chars, at) { + let mut s = at; + while s > 0 && space_at(&chars, s - 1) { + s -= 1; + } + let mut e = at; + while e < n && space_at(&chars, e) { + e += 1; + } + return (s, e); + } + let s = stops(text).into_iter().rev().find(|s| *s <= at).unwrap_or(0); + (s, content_end(text, s).max(at + 1).min(n)) +} + +/// What Ctrl+Delete removes from `ch` (before smart spacing): inside a token, the rest of its +/// text; on spaces right after a token, those spaces and the next token's text; anywhere else up +/// to the next stop. `None` at the end of the text. +pub fn delete_forward_end(text: &str, ch: usize) -> Option { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + if ch >= n { + return None; + } + let st = stops(text); + let at_stop = st.contains(&ch); + if !at_stop && !space_at(&chars, ch) { + return Some(content_end(text, ch)); + } + let prev_is_text = ch > 0 && chars.get(ch - 1).is_some_and(|c| !is_space(*c) && !matches!(base_class(*c), Class::Tab | Class::Break)); + if space_at(&chars, ch) && prev_is_text { + let mut j = ch; + while j < n && space_at(&chars, j) { + j += 1; + } + if j >= n { + return Some(n); + } + return Some(content_end(text, j)); + } + st.into_iter().find(|s| *s > ch) +} + +/// Closing punctuation that a smart delete pulls its word's spaces up to (`ab cd, ef` → Ctrl+Delete +/// on `cd` gives `ab, ef`). +pub fn is_closing_punct(c: char) -> bool { + matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '%' | '\u{2026}' | '\u{201d}' | '\u{2019}' | '\u{bb}') +} + +/// Punctuation that a smart delete keeps a space after when a word follows (`cd...ef gh` → +/// Ctrl+Delete on `ef` gives `cd... gh`). +pub fn is_sentence_punct(c: char) -> bool { + matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '\u{2026}') +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Stops drawn as `|` in the text, for readable assertions. + fn marked(text: &str, st: &[usize]) -> String { + let mut out = String::new(); + for (i, c) in text.chars().enumerate() { + if st.contains(&i) { + out.push('|'); + } + out.push(match c { + '\u{b}' => '↵', + '\t' => '→', + c => c, + }); + } + if st.contains(&text.chars().count()) { + out.push('|'); + } + out + } + fn fwd(text: &str) -> String { + marked(text, &stops(text)) + } + fn back(text: &str) -> String { + marked(text, &back_stops(text)) + } + + // Every expected string below is a stop trace observed in PowerPoint for Windows. + #[test] + fn punctuation_and_spaces() { + let t = "Hello, world! Foo...bar baz-qux it's 3.14 e.g. a_b x@y.com (end) \"q\" end."; + let want = "|Hello|, |world|! |Foo|...|bar |baz-qux |it's |3.14 |e|.|g|. |a|_|b |x|@|y|.|com |(|end|) |\"|q|\" |end|.|"; + assert_eq!(fwd(t), want); + assert_eq!(back(t), want); + } + + #[test] + fn hyphens_apostrophes_numbers() { + let t = "a-b -b a--b a - b a- b dogs' x 'q' 1,000.5 3:45 v1.2.3 $5 50% a/b x&y #tag a+b c++ end"; + assert_eq!( + fwd(t), + "|a-b |-|b |a-|-|b |a |- |b |a- |b |dogs|' |x |'q|' |1,000.5 |3:45 |v1.2.3 |$|5 |50|% |a|/|b |x|&|y |#|tag |a|+|b |c|++ |end|" + ); + let t = "it’s x a?!b a.)b a),b 1.a a1.b 1.5.x 1-2 a1-b A-1 —x x— a's 'tis o'clock ‘q’ end"; + assert_eq!(fwd(t), "|it’s |x |a|?!|b |a|.)|b |a|),|b |1|.|a |a1|.|b |1.5|.|x |1-2 |a1-b |A-1 |—|x |x|— |a's |'tis |o'clock |‘|q|’ |end|"); + } + + #[test] + fn marks_emoji_symbols() { + let t = "café e\u{301}te 👍🏽 x 👨\u{200d}👩\u{200d}👧 y ½ ®z «q» “w” ¿no? ok—em dash–en x"; + let want = "|café |e\u{301}te |👍🏽 |x |👨\u{200d}👩\u{200d}👧 |y |½ |®|z |«|q|» |“|w|” |¿|no|? |ok|—|em |dash|–|en |x|"; + assert_eq!(fwd(t), want); + assert_eq!(back(t), want); + // A run of emoji is one token; emoji then punctuation is two going right, one going left. + assert_eq!(fwd("👍👍 x 👍? x👍y"), "|👍👍 |x |👍|? |x|👍|y|"); + assert_eq!(back("👍👍 x 👍? x👍y"), "|👍👍 |x |👍? |x|👍|y|"); + } + + #[test] + fn cjk_each_character() { + let t = "日本語のテキストです。カタカナ漢字 abc 中文字符,标点。한국어 문장 a。。b"; + assert_eq!(fwd(t), "|日|本|語|の|テ|キ|ス|ト|で|す|。|カ|タ|カ|ナ|漢|字 |abc |中|文|字|符|,|标|点|。|한|국|어 |문|장 |a|。。|b|"); + assert_eq!(back(t), fwd(t)); + } + + #[test] + fn rtl_words_in_logical_order() { + let t = "Hello مرحبا بالعالم world שלום עולם end"; + assert_eq!(fwd(t), "|Hello |مرحبا |بالعالم |world |שלום |עולם |end|"); + } + + #[test] + fn tabs_breaks_and_other_spaces() { + assert_eq!(fwd(" lead x mid "), "| |lead |x |mid |"); + assert_eq!(fwd("Five\u{b}six seven\tTab x"), "|Five|↵|six |seven|→|Tab |x|"); + assert_eq!(fwd("a\t b x \u{b} y q\u{b}\u{b}z"), "|a|→| |b |x |↵| |y |q|↵|↵|z|"); + let t = "a\u{a0}\u{a0}b a\u{3000}b a\u{2003}b a \u{a0}b a\t\tb a \tb a\u{a0} b end"; + assert_eq!(fwd(t), "|a\u{a0}\u{a0}|b |a\u{3000}|b |a\u{2003}|b |a \u{a0}|b |a|→|→|b |a |→|b |a\u{a0} |b |end|"); + assert_eq!(back(t), "|a|\u{a0}|\u{a0}|b |a|\u{3000}|b |a|\u{2003}|b |a |\u{a0}|b |a|→|→|b |a |→|b |a\u{a0} |b |end|"); + } + + #[test] + fn next_and_prev() { + let t = " lead x mid "; + assert_eq!(next_stop(t, 1), Some(2)); + assert_eq!(next_stop(t, 9), Some(11)); + assert_eq!(next_stop(t, 16), None); + assert_eq!(prev_stop(t, 1), Some(0)); + assert_eq!(prev_stop(t, 9), Some(7)); + assert_eq!(prev_stop(t, 15), Some(11)); + assert_eq!(prev_stop(t, 0), None); + assert_eq!(next_stop("", 0), None); + assert_eq!(prev_stop("", 0), None); + } + + #[test] + fn double_click_word() { + let t = "Hello world, foo bar"; + assert_eq!(word_at(t, 7), (6, 11)); + assert_eq!(word_at(t, 15), (14, 17)); + assert_eq!(word_at(t, 11), (11, 12)); + assert_eq!(word_at(t, 12), (11, 12)); + assert_eq!(word_at(t, 13), (12, 14)); + assert_eq!(word_at(t, 21), (18, 21)); + assert_eq!(word_at("日本語のテキスト abc", 5), (5, 6)); + assert_eq!(word_at("", 3), (0, 0)); + assert_eq!(word_at("x👨\u{200d}👩\u{200d}👧y", 2), (1, 6)); + } + + #[test] + fn delete_forward_extent() { + let t = "ab cd...ef gh"; + let ends: Vec> = (0..=t.len()).map(|c| delete_forward_end(t, c)).collect(); + // Before smart spacing: PowerPoint's results after it are tested in `edit`. + assert_eq!( + ends, + vec![ + Some(5), + Some(2), + Some(7), + Some(5), + Some(5), + Some(7), + Some(7), + Some(10), + Some(10), + Some(10), + Some(13), + Some(12), + Some(15), + Some(15), + Some(15), + None + ] + ); + assert_eq!(delete_forward_end("ab\t cd", 2), Some(3)); + assert_eq!(delete_forward_end("ab ", 2), Some(4)); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 791f257b..ba58f920 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1860,24 +1860,70 @@ impl TextLayout { } /// Vertical caret movement: the position on the line above/below closest to `x`. pub fn vertical(&self, pos: Pos, down: bool, x: f64) -> Pos { - let Some(li) = self.line_of(pos) else { return pos }; - let target = if down { li + 1 } else { li.wrapping_sub(1) }; - let Some(l) = self.lines.get(target) else { return pos }; + self.vertical_affine(pos, Affinity::Downstream, down, x).0 + } + /// The line a caret is on. An upstream caret at the start of a soft-wrapped line (where End + /// leaves it, after the previous line's trailing spaces) belongs to the line before. + pub fn line_index(&self, pos: Pos, affinity: Affinity) -> Option { + if affinity == Affinity::Upstream && pos.ch > 0 { + for (i, w) in self.lines.windows(2).enumerate() { + if let [a, b] = w + && b.para == pos.para + && a.para == pos.para + && b.start == pos.ch + { + return Some(i); + } + } + } + self.line_of_affine(pos, affinity) + } + /// Up/Down from a caret with its affinity: the position on the line above/below nearest `x`. + pub fn vertical_affine(&self, pos: Pos, affinity: Affinity, down: bool, x: f64) -> (Pos, Affinity) { + let Some(li) = self.line_index(pos, affinity) else { return (pos, affinity) }; + let target = if down { li.checked_add(1) } else { li.checked_sub(1) }; + let Some(l) = target.and_then(|t| self.lines.get(t)) else { return (pos, affinity) }; let p = Point::new(x, (l.top + l.bottom) / 2.0); let mut h = self.hit(p); if h.para != l.para { h.para = l.para; - h.ch = h.ch.clamp(l.start, l.end); } - h + h.ch = h.ch.clamp(l.start, l.end); + (h, Affinity::Downstream) } /// Start/end of the visual line containing `pos` (logical: Home/End). pub fn line_bounds(&self, pos: Pos) -> (Pos, Pos) { - match self.line_of(pos).and_then(|i| self.lines.get(i)) { + self.line_bounds_affine(pos, Affinity::Downstream) + } + /// Start/end of the visual line a caret with this affinity is on. + pub fn line_bounds_affine(&self, pos: Pos, affinity: Affinity) -> (Pos, Pos) { + match self.line_index(pos, affinity).and_then(|i| self.lines.get(i)) { Some(l) => (Pos { para: l.para, ch: l.start }, Pos { para: l.para, ch: l.end }), None => (pos, pos), } } + /// Where End puts the caret: after a soft wrap, past the line's trailing spaces at the start + /// of the next line with upstream affinity (so it is still drawn on this line), else the end + /// of the paragraph. + pub fn line_end_caret(&self, pos: Pos, affinity: Affinity) -> (Pos, Affinity) { + let Some(li) = self.line_index(pos, affinity) else { return (pos, Affinity::Downstream) }; + let Some(l) = self.lines.get(li) else { return (pos, Affinity::Downstream) }; + match self.lines.get(li + 1) { + Some(n) if n.para == l.para && n.start > l.start => (Pos { para: l.para, ch: n.start }, Affinity::Upstream), + _ => (Pos { para: l.para, ch: l.end }, Affinity::Downstream), + } + } + /// The caret to draw (x, top, bottom): an upstream caret after a soft wrap is drawn at the end + /// of the line it belongs to. + pub fn caret_drawn(&self, pos: Pos, affinity: Affinity) -> Option<(f64, f64, f64)> { + let li = self.line_index(pos, affinity)?; + let l = self.lines.get(li)?; + if pos.ch > l.end { + let x = l.caret_x.last().copied()?; + return Some((x, l.top, l.bottom)); + } + self.caret_at(pos, affinity) + } /// Bounding box of all glyphs/lines (shape-local). pub fn bounds(&self) -> Rect { let mut r: Option = None; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 25a8323f..e5368781 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1036,6 +1036,89 @@ mod tests { assert_eq!(harness.state().session.documents().len(), 2); } + /// #268: Ctrl+Backspace, Ctrl+Delete, Ctrl+arrows and Ctrl+Shift+arrows in text, through real + /// egui key events (Windows keys). + #[test] + fn ctrl_word_keys_edit_text() { + use egui::{Key, Modifiers}; + let mut session = Session::with_new(); + session.mac_keys = false; + session.prefs.autocorrect = false; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, Services::default()), + ); + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.event(egui::Event::Text("Hello, world. Foo bar".into())); + harness.run_steps(1); + assert_eq!(title_text(harness.state()), "Hello, world. Foo bar"); + let caret = |h: &egui_kittest::Harness<'_, SlideApp>| h.state().session.active().unwrap().selection.text.clone().unwrap(); + let ctrl = Modifiers::COMMAND; + let ctrl_shift = Modifiers::COMMAND | Modifiers::SHIFT; + + // Ctrl+Left stops at every word and punctuation run, as in PowerPoint. + let mut stops = vec![]; + for _ in 0..6 { + harness.key_press_modifiers(ctrl, Key::ArrowLeft); + harness.run_steps(1); + stops.push(caret(&harness).caret.1); + } + assert_eq!(stops, vec![19, 15, 12, 7, 5, 0]); + harness.key_press_modifiers(ctrl, Key::ArrowRight); + harness.run_steps(1); + assert_eq!(caret(&harness).caret, (0, 5)); + + // Ctrl+Shift+Right selects to the next stops. + harness.key_press_modifiers(ctrl_shift, Key::ArrowRight); + harness.key_press_modifiers(ctrl_shift, Key::ArrowRight); + harness.run_steps(1); + let t = caret(&harness); + assert_eq!((t.anchor, t.caret), ((0, 5), (0, 12))); + + // Ctrl+Delete deletes the next word from the selection's start; Ctrl+Backspace the one + // before the caret. + harness.key_press_modifiers(ctrl, Key::Delete); + harness.run_steps(1); + assert_eq!(title_text(harness.state()), "Helloworld. Foo bar"); + harness.key_press(Key::End); + harness.key_press_modifiers(ctrl, Key::Backspace); + harness.run_steps(1); + assert_eq!(title_text(harness.state()), "Helloworld. Foo "); + + // Ctrl+Up/Down go by paragraph, Ctrl+Home/End to the text's ends. + harness.key_press(Key::Enter); + harness.event(egui::Event::Text("Next".into())); + harness.key_press_modifiers(ctrl, Key::ArrowUp); + harness.run_steps(1); + assert_eq!(caret(&harness).caret, (1, 0)); + harness.key_press_modifiers(ctrl, Key::ArrowUp); + harness.run_steps(1); + assert_eq!(caret(&harness).caret, (0, 0)); + harness.key_press_modifiers(ctrl, Key::ArrowDown); + harness.run_steps(1); + assert_eq!(caret(&harness).caret, (1, 0)); + harness.key_press_modifiers(ctrl_shift, Key::Home); + harness.run_steps(1); + let t = caret(&harness); + assert_eq!((t.anchor, t.caret), ((1, 0), (0, 0))); + + // Ctrl+Shift+> (egui reports the Period key) grows the selected text's font. + let size = |h: &mut egui_kittest::Harness<'_, SlideApp>| h.state_mut().session.execute("format.state", &json!({})).unwrap()["size"].as_f64(); + let before = size(&mut harness); + harness.key_press_modifiers(ctrl_shift, Key::Period); + harness.run_steps(1); + let after = size(&mut harness).unwrap(); + assert!(after > before.unwrap_or(0.0), "{before:?} → {after}"); + harness.key_press_modifiers(ctrl, Key::OpenBracket); + harness.run_steps(1); + assert!(size(&mut harness).unwrap_or(f64::MAX) < after, "Ctrl+[ shrinks"); + } + #[test] fn home_needs_an_open_presentation() { let mut app = SlideApp::new(Session::new(), Services::default()); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7ac73188..8a57ed4d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -2,6 +2,8 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) + How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs, direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in `plan/powerpoint/01-observed-ui.md` and `menu-tree.txt` (gitignored, clean-room rules in @@ -64,6 +66,35 @@ group/ungroup, bold/italic/underline, alignment, font size, list levels, Format start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag precision without snapping, F6 pane cycling, ribbon KeyTips (Windows). +### Text editing keys + +Observed black-box in PowerPoint for Windows (Microsoft 365, October 2026) on synthetic text, and +pinned by tests in `crates/model/src/words.rs`, `crates/model/src/edit.rs` and +`crates/engine/src/tests_keys.rs` (#268). Windows and Linux use Ctrl; the Mac uses Option for words +and Cmd for line and text ends (`Session::mac_keys`). + +| Keys | Behaviour | +|---|---| +| Ctrl+Left / Right | Previous / next word stop. A stop is the start of every word, punctuation run, symbol run, tab, line break and CJK character, plus the paragraph's end; spaces belong to the word before them. `it's`, `baz-qux`, `a-`, `3.14`, `1,000`, `3:45` are one word; `e.g.`, `a_b`, `x@y.com`, `dogs'` are not. Every CJK ideograph, kana and Hangul syllable is a word. Combining marks and emoji sequences never split. With a selection: Right goes on from its end, Left from its start | +| Ctrl+Shift+Left / Right | Moves the selection's moving end by the same stops | +| Ctrl+Up / Down | Start of the paragraph (then of the previous one) / start of the next paragraph (end of the text in the last one); Shift extends | +| Ctrl+Backspace | Deletes back to the previous stop (word plus its trailing spaces); at a paragraph's start joins it to the previous paragraph | +| Ctrl+Delete | Inside a word: the rest of it. On spaces after a word: the spaces and the next word. At a word's start: the word and its spaces. Then smart spacing: spaces left before closing punctuation (`ab cd, ef` → `ab, ef`) or at the paragraph's end after a word go; sentence punctuation left touching a word gets a space (`cd...ef gh` → `cd... gh`). At a paragraph's end joins the next paragraph | +| Ctrl+Backspace / Delete with a selection | Leave the selection and delete the word part before / after its start | +| Right-to-left paragraph | Left and Right (with or without Ctrl) swap: Left goes forward in the text. Arabic or Hebrew words in a left-to-right paragraph move in logical order | +| Home / End | Start / end of the visual line. End on a wrapped line goes past its trailing space and the caret stays drawn at that line's end; End again stays; Home from there returns to the same line's start | +| Ctrl+Home / End | Start / end of the text; Shift extends | +| Ctrl+Shift+> / <, Ctrl+] / [ | Next / previous size in the font size list (18 → 20 → 24) | +| Ctrl+B / I / U, Ctrl+Space | With a caret strictly inside a word, the whole word (without trailing spaces); at a word's edge, only what is typed next | +| Ctrl+E / L / R / J | Center / left / right / justify the paragraphs in the selection | +| Shift+F3 | Cycles lowercase → Capitalize Each Word → UPPERCASE → lowercase; other mixes go to UPPERCASE first. A caret in a word changes that word and moves to its start | +| Shift+Enter | Line break | +| Double-click / triple-click | The word without its trailing spaces (a CJK character on its own) / the paragraph including its paragraph mark | + +Quirks kept on purpose because PowerPoint has them: Ctrl+Left (not Ctrl+Right) also stops at +each no-break or ideographic space, and treats an emoji followed by punctuation as one word. Tab, Shift+Tab and +Alt+Shift+arrows in text are handled elsewhere (#78, #93). + ## Visual fidelity Colours, spacing, ribbon card and tab geometry follow the observations in @@ -91,4 +122,6 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock | + +| 2026-10-12 | minor | Text editing keys: Ctrl+arrows, Ctrl+Backspace/Delete, Ctrl+Up/Down, Home/End on wrapped lines, Shift+F3, font size keys, as observed in PowerPoint for Windows (#268) | | 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 | From d6ab3d10da419694cf445c3770105b36d07a0d77 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 22:26:34 -0400 Subject: [PATCH 39/49] UI: en and em dashes show in interface text (#334) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/theme.rs | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 33cbfbe8..1847d47f 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -191,6 +191,16 @@ pub fn font_definitions(extra: &[std::sync::Arc]) -> bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default()); defs.families.insert(FontFamily::Name("bold".into()), bold); add_fallback_faces(&mut defs, extra); + // The Japanese interface face (the first one when Inter isn't embedded) hints its en and em + // dashes, bars thinner than a pixel, down to nothing, so they drew as gaps in interface text; + // unhinted, they draw. + for (name, d) in defs.font_data.iter_mut() { + if name.contains("BIZ UDPGothic") { + let mut data = (**d).clone(); + data.tweak.hinting = Some(false); + *d = std::sync::Arc::new(data); + } + } defs } @@ -221,3 +231,38 @@ pub fn font(size: f32) -> egui::FontId { pub fn to_color32(c: deckcraft_color::Rgba) -> Color32 { Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a) } + +#[cfg(test)] +mod tests { + /// Each character of `text` laid out in the interface fonts at interface sizes, and whether it + /// has ink (a glyph image). + fn drawn(text: &str) -> Vec<(char, f32, bool)> { + let ctx = egui::Context::default(); + ctx.set_fonts(super::font_definitions(&[])); + let mut o = ctx.run_ui(Default::default(), |_| {}); + o.textures_delta.clear(); + let mut out = vec![]; + ctx.fonts_mut(|f| { + for size in [11.0, 12.0, 13.0, 16.0] { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name("bold".into())] { + let g = f.layout_no_wrap(text.to_string(), egui::FontId::new(size, family), egui::Color32::WHITE); + for gl in g.rows.iter().flat_map(|r| &r.glyphs) { + out.push((gl.chr, size, gl.uv_rect.size.x > 0.0 && gl.uv_rect.size.y > 0.0)); + } + } + } + }); + let mut o = ctx.run_ui(Default::default(), |_| {}); + o.textures_delta.clear(); + out + } + + #[test] + fn thin_punctuation_is_drawn_in_interface_text() { + // En and em dashes (thin bars) were hinted down to nothing in the Japanese interface face, + // so notes and the outline showed gaps where they were. + for (c, size, ink) in drawn("\u{2013}\u{2014}-_\u{2026}\u{2022}\u{00b7}\u{2018}\u{2019}\u{201c}\u{201d}=~") { + assert!(ink, "{c:?} ({:04X}) draws nothing at {size} px", c as u32); + } + } +} From e28851d2f1e561decbf220e237ac2f4a1f3223bb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 22:40:56 -0400 Subject: [PATCH 40/49] Pictures: WMF/EMF text, pattern and hatch brushes draw; ActiveX previews show (#336) * ActiveX controls show their pictures A slide's p:controls are read: each control with a p:pic (the image PowerPoint shows for it, often a WMF) becomes a picture above the slide's shapes. Co-Authored-By: Claude Opus 5.5 (1M context) * Metafiles draw their text, pattern and hatch brushes deckcraft-metafile (still kurbo + thiserror only, so it builds for wasm): - Text: WMF EXTTEXTOUT/TEXTOUT and EMF EXTTEXTOUTW/EXTTEXTOUTA/SMALLTEXTOUT with the selected font (CREATEFONTINDIRECT, EXTCREATEFONTINDIRECTW: face, signed LOGFONT height, width, weight, italic, underline, strike-out, escapement, charset), text and background colour, background mode, TA_* alignment and TA_UPDATECP, the dx array (ETO_PDY pairs too), ETO_OPAQUE fills and ETO_CLIPPED clips. 8-bit strings decode through their charset (Windows-1252/1250/1251; symbol fonts keep their codes at U+F000; double-byte sets keep their advances). The neutral Item::Text carries a text-space-to-picture transform, so the mapping and escapement are folded in and glyphs are never mirrored. - Brushes: WMF DIBCREATEPATTERNBRUSH, EMF CREATEDIBPATTERNBRUSHPT and CREATEMONOBRUSH, and BS_HATCHED, as Item::Pattern tiles in 96-dpi pixels; WMF PATBLT. The XOR / AND-pattern / XOR see-through shadow of old PowerPoint WMFs draws its pattern in the XOR colour. - MFCOMMENT escapes with an embedded EMF are ignored: PowerPoint draws the WMF's records. - Pattern pixels and text count against the byte budget; strings are capped. deckcraft-render draws text through deckcraft-fonts with the face slide text would use (document_face), symbol fonts mapped as bullets map them (deckcraft_text::symbol_char, now public, with Webdings), per-character fallback, the file's advances, GDI cell metrics for top/bottom alignment, underline/strike-out and synthetic bold/italic. Tiles below 1.5 output pixels per cell are drawn as their average colour. On the 24 corpus slides that show a metafile, mean abs diff against PowerPoint goes from 28.31 to 5.06 (with the OLE/ActiveX preview commits). Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ROADMAP.md | 4 +- crates/metafile/src/canvas.rs | 399 +++++++++++++++++++++++--- crates/metafile/src/emf.rs | 122 +++++++- crates/metafile/src/lib.rs | 73 ++++- crates/metafile/src/place.rs | 26 ++ crates/metafile/src/tests.rs | 504 ++++++++++++++++++++++++++++++++- crates/metafile/src/text.rs | 200 +++++++++++++ crates/metafile/src/wmf.rs | 121 +++++++- crates/pptx/src/read/mod.rs | 4 + crates/pptx/src/read/shapes.rs | 16 ++ crates/pptx/tests/ole_vml.rs | 84 ++++++ crates/render/src/metafile.rs | 259 ++++++++++++++++- crates/render/src/tests.rs | 175 ++++++++++++ crates/text/src/lib.rs | 28 ++ crates/text/src/tests.rs | 11 + docs/file-format-parity.md | 5 +- docs/gaps.md | 15 +- 17 files changed, 1979 insertions(+), 67 deletions(-) create mode 100644 crates/metafile/src/text.rs create mode 100644 crates/pptx/tests/ole_vml.rs diff --git a/ROADMAP.md b/ROADMAP.md index c93e1fdc..813474d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,12 +129,10 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | - | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | - | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | - | 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection | +| 2026-10-12 | EMF/WMF pictures draw their text (fonts, alignment, dx advances, escapement, symbol fonts), pattern and hatch brushes, PATBLT and the XOR/AND see-through shadow; legacy OLE objects show their VML preview and ActiveX controls their pictures | | 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index fa8bfdcf..4ffa56bf 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -2,8 +2,11 @@ //! transform, pen and brush selection through an object table, the DC save stack and path brackets. //! Output is device-space items; `lib.rs` maps them into the picture frame. +use std::sync::{Arc, OnceLock}; + use crate::dib::Rgba; use crate::geom::{Op, P}; +use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, TA_UPDATECP, Text, align}; use crate::{MAX_BYTES, MAX_ITEMS, MAX_OBJECTS, MAX_PATH_OPS, MAX_STACK}; pub(crate) const MM_TEXT: u32 = 1; @@ -15,9 +18,21 @@ pub(crate) const PS_NULL: u32 = 5; pub(crate) const PS_STYLE_MASK: u32 = 0x0F; const BS_SOLID: u32 = 0; pub(crate) const BS_NULL: u32 = 1; +pub(crate) const BS_HATCHED: u32 = 2; +/// BS_PATTERN, BS_DIBPATTERN and BS_DIBPATTERNPT: the brush paints a bitmap tile. +const BS_PATTERN: u32 = 3; +const BS_DIBPATTERN: u32 = 5; +const BS_DIBPATTERNPT: u32 = 6; const WINDING: u32 = 2; -/// SETROP2 mix mode that leaves the destination alone: outlines and fills draw nothing. +/// SETROP2 mix modes. R2_NOP leaves the destination alone: outlines and fills draw nothing. +/// R2_XORPEN and R2_MASKPEN draw the classic see-through shadow (XOR a colour, AND a pattern, XOR +/// the colour again); the others draw as R2_COPYPEN. +const R2_XORPEN: u32 = 7; +const R2_MASKPEN: u32 = 9; const R2_NOP: u32 = 11; +const R2_COPYPEN: u32 = 13; +/// SETBKMODE OPAQUE: text cells and hatch gaps are filled with the background colour. +const OPAQUE: u32 = 2; const PATCOPY: u32 = 0x00F0_0021; const BLACKNESS: u32 = 0x0000_0042; const WHITENESS: u32 = 0x00FF_0062; @@ -48,20 +63,78 @@ pub(crate) struct Pen { pub color: [u8; 4], } -#[derive(Clone, Copy, PartialEq, Debug)] +#[derive(Clone, PartialEq, Debug)] pub(crate) struct Brush { pub style: u32, pub color: [u8; 4], + /// HS_* hatch style of a BS_HATCHED brush. + pub hatch: u32, + /// The tile of a pattern brush. + pub pattern: Option>, } -#[derive(Clone, Copy, Debug)] +impl Brush { + pub fn new(style: u32, color: [u8; 4]) -> Self { + Brush { style, color, hatch: 0, pattern: None } + } + + /// A pattern brush painting `img`. A monochrome brush paints its dark pixels in the text + /// colour and its light ones in the background colour. None for an empty or oversized tile. + pub fn pattern(img: Rgba, mono: bool) -> Option { + if img.w == 0 || img.h == 0 || img.w > MAX_TILE_SIDE || img.h > MAX_TILE_SIDE || img.px.len() != img.w * img.h * 4 { + return None; + } + let pattern = Arc::new(Pattern { w: img.w, h: img.h, px: img.px, mono, plain: OnceLock::new() }); + Some(Brush { style: BS_DIBPATTERNPT, color: [0, 0, 0, 255], hatch: 0, pattern: Some(pattern) }) + } +} + +/// A pattern brush's tile, top-down RGBA. +#[derive(PartialEq, Debug)] +pub(crate) struct Pattern { + pub w: usize, + pub h: usize, + pub px: Vec, + pub mono: bool, + /// The tile as drawn when nothing recolours it, shared by every fill with this brush. + pub plain: OnceLock>, +} + +/// Largest pattern tile side kept (GDI brushes are typically 8×8). +pub(crate) const MAX_TILE_SIDE: usize = 256; + +#[derive(Clone, Debug)] pub(crate) enum Obj { Pen(Pen), Brush(Brush), - /// Fonts, palettes, pattern brushes and regions: they take a table slot but draw nothing. + Font(Arc), + /// Palettes and regions: they take a table slot but draw nothing. Other, } +/// A font object: the neutral [`Font`] and its escapement (tenths of a degree, counter-clockwise), +/// which the text transform takes. +#[derive(Debug, Clone, PartialEq, Default)] +pub(crate) struct LogFont { + pub font: Font, + pub escapement: f64, +} + +/// A brush tile in device units: `cell` device units per tile pixel, laid from the device origin. +#[derive(Debug, PartialEq)] +pub(crate) struct RawTile { + pub w: usize, + pub h: usize, + pub px: Vec, + pub cell: f64, +} + +/// What a fill paints. +enum Paint { + Solid([u8; 4]), + Tile(Arc), +} + /// Converts a COLORREF (0x00BBGGRR) to opaque RGBA. pub(crate) fn colorref(c: u32) -> [u8; 4] { [(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8, ((c >> 16) & 0xFF) as u8, 255] @@ -166,8 +239,34 @@ fn ratio(num: f64, den: f64) -> f64 { /// Output of drawing, in device units. Path points are already mapped; `lib.rs` normalises them. pub(crate) enum Raw { - Path { ops: Vec, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool }, - Bitmap { rect: [f64; 4], img: Rgba }, + Path { + ops: Vec, + fill: Option<[u8; 4]>, + stroke: Option<([u8; 4], f64)>, + even_odd: bool, + }, + /// A path filled with a repeating tile. + Pattern { + ops: Vec, + even_odd: bool, + tile: Arc, + }, + Bitmap { + rect: [f64; 4], + img: Rgba, + }, + /// Text whose transform and clip are in device units. + Text(Box), +} + +/// Text drawing state: colours, background mode, alignment and the selected font. +#[derive(Clone)] +struct TextState { + color: [u8; 4], + bk_color: [u8; 4], + bk_opaque: bool, + align: u32, + font: Option>, } #[derive(Clone)] @@ -176,7 +275,8 @@ struct Saved { pen: Pen, brush: Brush, even_odd: bool, - nop: bool, + rop2: u32, + text: TextState, } pub(crate) struct Canvas { @@ -193,8 +293,13 @@ pub(crate) struct Canvas { pen: Pen, brush: Brush, even_odd: bool, - /// The mix mode is R2_NOP: shapes draw nothing. - nop: bool, + /// SETROP2 mix mode. + rop2: u32, + /// The colour of the last fill drawn with R2_XORPEN, for the see-through shadow idiom. + xor_color: Option<[u8; 4]>, + text: TextState, + /// Device units per pattern-brush pixel: brushes are laid out in 96-dpi pixels. + pub pattern_px: f64, pos: P, in_path: bool, path: Vec, @@ -212,9 +317,12 @@ impl Canvas { used: 0, objects: Vec::new(), pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] }, - brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] }, + brush: Brush::new(BS_SOLID, [255, 255, 255, 255]), even_odd: true, - nop: false, + rop2: R2_COPYPEN, + xor_color: None, + text: TextState { color: [0, 0, 0, 255], bk_color: [255, 255, 255, 255], bk_opaque: true, align: 0, font: None }, + pattern_px: 1.0, pos: (0.0, 0.0), in_path: false, path: Vec::new(), @@ -230,7 +338,14 @@ impl Canvas { fn push(&mut self, raw: Raw) { let cost = match &raw { Raw::Path { ops, .. } => ops.len() * std::mem::size_of::(), + // A tile shared with its brush is counted once (by the brush); one made for this fill + // counts here. + Raw::Pattern { ops, tile, .. } => { + let own = if Arc::strong_count(tile) == 1 { tile.px.len() } else { 0 }; + ops.len() * std::mem::size_of::() + 64 + own + } Raw::Bitmap { img, .. } => img.px.len(), + Raw::Text(t) => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128, }; if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES { self.full = true; @@ -288,9 +403,31 @@ impl Canvas { self.even_odd = mode != WINDING; } - /// SETROP2: only R2_NOP changes what is drawn (nothing); the other mix modes draw as copy. + /// SETROP2. R2_NOP draws nothing; R2_XORPEN and R2_MASKPEN are read as the see-through shadow + /// idiom (see [`Canvas::fill_paint`]); the other mix modes draw as copy. pub fn set_rop2(&mut self, mode: u32) { - self.nop = mode == R2_NOP; + self.rop2 = mode; + } + + pub fn set_text_color(&mut self, c: u32) { + self.text.color = colorref(c); + } + + pub fn set_bk_color(&mut self, c: u32) { + self.text.bk_color = colorref(c); + } + + pub fn set_bk_mode(&mut self, mode: u32) { + self.text.bk_opaque = mode == OPAQUE; + } + + pub fn set_text_align(&mut self, ta: u32) { + self.text.align = ta; + } + + /// The character set of the selected font (ANSI without one). + pub fn font_charset(&self) -> u8 { + self.text.font.as_ref().map_or(0, |f| f.font.charset) } /// EMF SETWORLDTRANSFORM (`mode` 4) and MODIFYWORLDTRANSFORM (1 identity, 2 left, 3 right, 4 set). @@ -309,7 +446,14 @@ impl Canvas { /// could no longer match the file. pub fn save_dc(&mut self) { if self.saved.len() < MAX_STACK { - self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop }); + self.saved.push(Saved { + map: self.map, + pen: self.pen, + brush: self.brush.clone(), + even_odd: self.even_odd, + rop2: self.rop2, + text: self.text.clone(), + }); } else { self.full = true; } @@ -325,11 +469,21 @@ impl Canvas { self.pen = s.pen; self.brush = s.brush; self.even_odd = s.even_odd; - self.nop = s.nop; + self.rop2 = s.rop2; + self.text = s.text; } /// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table). pub fn add_object(&mut self, slot: Option, obj: Obj) { + // A pattern brush's pixels count against the byte budget like drawn items. + if let Obj::Brush(Brush { pattern: Some(p), .. }) = &obj { + let cost = p.px.len(); + if self.used.saturating_add(cost) > MAX_BYTES { + self.full = true; + return; + } + self.used += cost; + } match slot { Some(i) if i < MAX_OBJECTS => { if self.objects.len() <= i { @@ -363,9 +517,10 @@ impl Canvas { if i & STOCK != 0 { return self.select_stock(i & !STOCK); } - match self.objects.get(i as usize).copied().flatten() { + match self.objects.get(i as usize).cloned().flatten() { Some(Obj::Pen(p)) => self.pen = p, Some(Obj::Brush(b)) => self.brush = b, + Some(Obj::Font(f)) => self.text.font = Some(f), Some(Obj::Other) | None => {} } } @@ -373,17 +528,19 @@ impl Canvas { fn select_stock(&mut self, i: u32) { let white = [255, 255, 255, 255]; let black = [0, 0, 0, 255]; - let gray = |v: u8| Brush { style: BS_SOLID, color: [v, v, v, 255] }; + let gray = |v: u8| Brush::new(BS_SOLID, [v, v, v, 255]); match i { - 0 | 18 => self.brush = Brush { style: BS_SOLID, color: white }, + 0 | 18 => self.brush = Brush::new(BS_SOLID, white), 1 => self.brush = gray(192), 2 => self.brush = gray(128), 3 => self.brush = gray(64), - 4 => self.brush = Brush { style: BS_SOLID, color: black }, - 5 => self.brush = Brush { style: BS_NULL, color: black }, + 4 => self.brush = Brush::new(BS_SOLID, black), + 5 => self.brush = Brush::new(BS_NULL, black), 6 => self.pen = Pen { style: 0, width: 0.0, color: white }, 7 | 19 => self.pen = Pen { style: 0, width: 0.0, color: black }, 8 => self.pen = Pen { style: PS_NULL, width: 0.0, color: black }, + // The stock fonts (OEM_FIXED_FONT … DEFAULT_GUI_FONT): drawn in the default face. + 10..=17 => self.text.font = None, _ => {} } } @@ -397,8 +554,81 @@ impl Canvas { } } - fn fill_color(&self) -> Option<[u8; 4]> { - (self.brush.style == BS_SOLID).then_some(self.brush.color) + /// The current brush's paint. Hatch and monochrome brushes take the background colour (when + /// the background mode is opaque) and the text colour. Under R2_MASKPEN (destination AND + /// brush) a tile's white pixels keep what is below, and its black ones take the colour of the + /// XOR fill before it: XOR, AND, XOR again leaves that colour where the tile is black. + fn fill_paint(&self) -> Option { + let b = &self.brush; + match b.style { + BS_SOLID => Some(Paint::Solid(b.color)), + BS_HATCHED => { + let bg = self.text.bk_opaque.then_some(self.text.bk_color); + let px = hatch_tile(b.hatch, b.color, bg)?; + Some(Paint::Tile(Arc::new(RawTile { w: 8, h: 8, px, cell: self.pattern_px }))) + } + BS_PATTERN | BS_DIBPATTERN | BS_DIBPATTERNPT => { + let p = b.pattern.as_ref()?; + if !p.mono && self.rop2 != R2_MASKPEN { + let cell = self.pattern_px; + return Some(Paint::Tile(p.plain.get_or_init(|| Arc::new(RawTile { w: p.w, h: p.h, px: p.px.clone(), cell })).clone())); + } + let mut px = p.px.clone(); + for c in px.as_chunks_mut::<4>().0 { + if p.mono { + let light = u16::from(c[0]) + u16::from(c[1]) + u16::from(c[2]) >= 384; + let v = if light { self.text.bk_color } else { self.text.color }; + *c = [v[0], v[1], v[2], 255]; + } + if self.rop2 == R2_MASKPEN { + if c[..3] == [255, 255, 255] { + c[3] = 0; + } else if c[..3] == [0, 0, 0] + && let Some(x) = self.xor_color + { + *c = x; + } + } + } + Some(Paint::Tile(Arc::new(RawTile { w: p.w, h: p.h, px, cell: self.pattern_px }))) + } + _ => None, + } + } + + /// Pushes device ops filled with `fill` and stroked with `stroke`: a tile fill and its outline + /// are two items. + fn push_shape(&mut self, ops: Vec, fill: Option, stroke: Option<([u8; 4], f64)>) { + let even_odd = self.even_odd; + match fill { + Some(Paint::Tile(tile)) => { + if stroke.is_some() { + self.push(Raw::Pattern { ops: ops.clone(), even_odd, tile }); + self.push(Raw::Path { ops, fill: None, stroke, even_odd }); + } else { + self.push(Raw::Pattern { ops, even_odd, tile }); + } + } + Some(Paint::Solid(c)) => self.push(Raw::Path { ops, fill: Some(c), stroke, even_odd }), + None if stroke.is_some() => self.push(Raw::Path { ops, fill: None, stroke, even_odd }), + None => {} + } + } + + /// Whether the mix mode suppresses drawing: R2_NOP draws nothing, and R2_XORPEN shapes only + /// remember their fill colour (an XOR pair cancels; the see-through shadow idiom draws its + /// pattern in that colour). + fn mix_skips(&mut self, fill: bool) -> bool { + match self.rop2 { + R2_NOP => true, + R2_XORPEN => { + if fill && self.brush.style == BS_SOLID { + self.xor_color = Some(self.brush.color); + } + true + } + _ => false, + } } fn stroke_pen(&self) -> Option<([u8; 4], f64)> { @@ -426,16 +656,12 @@ impl Canvas { if self.in_path { return self.push_path_ops(dev); } - if self.nop { + if self.mix_skips(fill) || dev.len() < 2 { return; } - let fill = if fill { self.fill_color() } else { None }; + let fill = if fill { self.fill_paint() } else { None }; let stroke = if stroke { self.stroke_pen() } else { None }; - if (fill.is_none() && stroke.is_none()) || dev.len() < 2 { - return; - } - let even_odd = self.even_odd; - self.push(Raw::Path { ops: dev, fill, stroke, even_odd }); + self.push_shape(dev, fill, stroke); } pub fn move_to(&mut self, p: P) { @@ -508,23 +734,22 @@ impl Canvas { /// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it. pub fn paint_path(&mut self, fill: bool, stroke: bool) { let ops = std::mem::take(&mut self.path); - if self.nop { + if self.mix_skips(fill) || ops.len() < 2 { return; } - let fill = if fill { self.fill_color() } else { None }; + let fill = if fill { self.fill_paint() } else { None }; let stroke = if stroke { self.stroke_pen() } else { None }; - if ops.len() < 2 || (fill.is_none() && stroke.is_none()) { - return; - } - let even_odd = self.even_odd; - self.push(Raw::Path { ops, fill, stroke, even_odd }); + self.push_shape(ops, fill, stroke); } - /// Fills a logical rectangle `[l, t, r, b]` with the current brush (nothing when it is not solid). - pub fn fill_brush_rect(&mut self, rect: [f64; 4]) { - if let Some(color) = self.fill_color() { - self.fill_rect_with(rect, color); + /// Fills a logical rectangle `[l, t, r, b]` with the current brush (PATBLT, PATCOPY blits). + pub fn fill_brush_rect(&mut self, [l, t, r, b]: [f64; 4]) { + if self.mix_skips(true) { + return; } + let fill = self.fill_paint(); + let dev: Vec = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect(); + self.push_shape(dev, fill, None); } /// Fills a logical rectangle `[l, t, r, b]` with a solid colour (ROP fills such as BLACKNESS). @@ -550,6 +775,75 @@ impl Canvas { } } + /// Draws `text` (one `dx` advance per character, or none) with the current font, colours and + /// alignment. The reference point is `at`, or the current position under TA_UPDATECP (which + /// then moves past the string). `rect` (logical `[l, t, r, b]`) is filled with the background + /// colour under ETO_OPAQUE and clips the text under ETO_CLIPPED. Text ignores the mix mode, as + /// GDI's does. + pub fn text(&mut self, at: P, text: String, dx: Vec, options: u32, rect: Option<[f64; 4]>) { + if let (true, Some(r)) = (options & ETO_OPAQUE != 0, rect) { + self.fill_rect_with(r, self.text.bk_color); + } + let lf = self.text.font.as_deref().cloned().unwrap_or_default(); + let update = self.text.align & TA_UPDATECP != 0; + let origin = if update { self.pos } else { at }; + let (h_align, v_align) = align(self.text.align); + let o = self.map.device(origin); + let ux = self.map.device((origin.0 + 1.0, origin.1)); + let uy = self.map.device((origin.0, origin.1 + 1.0)); + let (ux, uy) = ((ux.0 - o.0, ux.1 - o.1), (uy.0 - o.0, uy.1 - o.1)); + if update { + // The current position moves along the baseline past the string (back past it when + // right-aligned). Without a dx array the advance is estimated from the height. + let adv: f64 = if dx.is_empty() { + f64::from(lf.font.height.abs().max(1.0)) * 0.5 * text.chars().count() as f64 + } else { + dx.iter().map(|d| f64::from(*d)).sum() + }; + let adv = match h_align { + crate::HAlign::Left => adv, + crate::HAlign::Right => -adv, + crate::HAlign::Center => 0.0, + }; + let theta = (lf.escapement / 10.0).to_radians(); + let y_sign = if uy.1 < 0.0 { 1.0 } else { -1.0 }; + let p = (origin.0 + adv * theta.cos(), origin.1 + adv * theta.sin() * y_sign); + if p.0.is_finite() && p.1.is_finite() { + self.pos = p; + } + } + let (sx, sy) = (ux.0.hypot(ux.1), uy.0.hypot(uy.1)); + let drawable = !text.is_empty() && [sx, sy, o.0, o.1].iter().all(|v| v.is_finite()) && sx > 0.0 && sy > 0.0; + if !drawable { + return; + } + // The baseline runs along the device direction of the logical x axis (rotated by a world + // transform; a mirroring mapping does not mirror glyphs), turned counter-clockwise by the + // escapement. + let det = ux.0 * uy.1 - ux.1 * uy.0; + let phi = if det > 0.0 { ux.1.atan2(ux.0) } else { ux.1.atan2(ux.0.abs()) }; + let a = phi - (lf.escapement / 10.0).to_radians(); + let (c, s) = (a.cos(), a.sin()); + let clip = match (options & ETO_CLIPPED != 0, rect) { + (true, Some([l, t, r, b])) => { + let (p, q) = (self.map.device((l, t)), self.map.device((r, b))); + Some([p.0.min(q.0) as f32, p.1.min(q.1) as f32, p.0.max(q.0) as f32, p.1.max(q.1) as f32]) + } + _ => None, + }; + self.push(Raw::Text(Box::new(Text { + transform: [sx * c, sx * s, -sy * s, sy * c, o.0, o.1], + text, + dx, + font: lf.font, + color: self.text.color, + background: self.text.bk_opaque.then_some(self.text.bk_color), + h_align, + v_align, + clip, + }))); + } + /// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the /// bitmap, as the mapping does. pub fn bitmap(&mut self, rect: [f64; 4], mut img: Rgba) { @@ -562,6 +856,31 @@ impl Canvas { } } +/// An 8×8 hatch tile (HS_HORIZONTAL, HS_VERTICAL, HS_FDIAGONAL, HS_BDIAGONAL, HS_CROSS, +/// HS_DIAGCROSS): one-pixel lines in `fg` over `bg`, or over nothing when the background is +/// transparent. None for an unknown style. +pub(crate) fn hatch_tile(style: u32, fg: [u8; 4], bg: Option<[u8; 4]>) -> Option> { + if style > 5 { + return None; + } + let mut px = Vec::with_capacity(8 * 8 * 4); + for y in 0..8u32 { + for x in 0..8u32 { + let (h, v, f, b) = (y == 7, x == 7, x == y, x + y == 7); + let on = match style { + 0 => h, + 1 => v, + 2 => f, + 3 => b, + 4 => h || v, + _ => f || b, + }; + px.extend(if on { fg } else { bg.unwrap_or([0, 0, 0, 0]) }); + } + } + Some(px) +} + /// Cubic segments from groups of three points (control, control, end). A trailing partial group is /// dropped. fn cubics(pts: &[P]) -> Vec { diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index 027cfae9..709537f7 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -1,12 +1,20 @@ //! EMF reader (MS-EMF). Records are (type, size in bytes) followed by a payload; offsets inside a //! record (bitmap info and bits) are relative to the record start, so each handler reads the record slice. +use std::sync::Arc; + use crate::bytes::Bytes; -use crate::canvas::{BS_NULL, Brush, Canvas, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref}; +use crate::canvas::{BS_HATCHED, BS_NULL, Brush, Canvas, LogFont, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref}; use crate::dib::{self, Blend}; use crate::geom::{self, ArcKind, P}; +use crate::text::{ETO_GLYPH_INDEX, ETO_PDY, Font, MAX_CHARS, decode_bytes, decode_utf16}; use crate::{Error, Frame, MAX_PATH_OPS}; +/// ExtTextOut options: no rectangle in the record (EMR_SMALLTEXTOUT, EmrText), and 8-bit +/// characters (EMR_SMALLTEXTOUT). +const ETO_NO_RECT: u32 = 0x0100; +const ETO_SMALL_CHARS: u32 = 0x0200; + const EMF_SIGNATURE: u32 = 0x464D_4520; /// Hundredths of a millimetre to points. const HMM_TO_PT: f64 = 72.0 / 2540.0; @@ -100,6 +108,14 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) { 38 => create_pen(cv, r), 95 => create_ext_pen(cv, r), 39 => create_brush(cv, r), + 93 | 94 => create_dib_brush(cv, r, typ == 93), + 82 => create_font(cv, r), + 18 => r.u32(8).map(|m| cv.set_bk_mode(m)), + 22 => r.u32(8).map(|a| cv.set_text_align(a)), + 24 => r.u32(8).map(|c| cv.set_text_color(c)), + 25 => r.u32(8).map(|c| cv.set_bk_color(c)), + 83 | 84 => ext_text_out(cv, r, typ == 84), + 108 => small_text_out(cv, r), 40 => r.u32(8).map(|i| cv.delete_object(i)), 27 => xy32(r, 8).map(|p| cv.move_to(p)), 54 => xy32(r, 8).map(|p| cv.line_to(p)), @@ -246,7 +262,109 @@ fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> { let slot = r.u32(8)? as usize; let style = r.u32(12)?; let color = r.u32(16)?; - cv.add_object(Some(slot), Obj::Brush(Brush { style, color: colorref(color) })); + let mut b = Brush::new(style, colorref(color)); + if style == BS_HATCHED { + b.hatch = r.u32(20).unwrap_or(0); + } + cv.add_object(Some(slot), Obj::Brush(b)); + Some(()) +} + +/// EMR_CREATEDIBPATTERNBRUSHPT (94) and EMR_CREATEMONOBRUSH (93): handle, colour usage, then the +/// bitmap info and bits as offset/size pairs. A mono brush (or one whose palette holds indices, +/// DIB_PAL_COLORS) takes the text and background colours. An undecodable bitmap leaves the slot +/// empty. +fn create_dib_brush(cv: &mut Canvas, r: Bytes, mono: bool) -> Option<()> { + let slot = r.u32(8)? as usize; + let usage = r.u32(12)?; + let (bo, bl) = (r.u32(16)? as usize, r.u32(20)? as usize); + let (po, pl) = (r.u32(24)? as usize, r.u32(28)? as usize); + let img = dib::decode(Bytes(r.slice(bo, bl)?), Bytes(r.slice(po, pl)?), false)?; + let b = Brush::pattern(img, mono || usage == 1)?; + cv.add_object(Some(slot), Obj::Brush(b)); + Some(()) +} + +/// EMR_EXTCREATEFONTINDIRECTW: handle, then a LogFont (MS-EMF 2.2.13): height, width, escapement, +/// orientation, weight (i32 each), italic, underline, strike-out, charset and four more bytes, then +/// the face name in 32 UTF-16 units (NUL-terminated). +fn create_font(cv: &mut Canvas, r: Bytes) -> Option<()> { + let slot = r.u32(8)? as usize; + let height = r.i32(12)?; + let width = r.i32(16).unwrap_or(0); + let escapement = r.i32(20).unwrap_or(0); + let weight = r.i32(28).unwrap_or(0); + let byte = |o: usize| r.0.get(o).copied().unwrap_or(0); + let units: Vec = (0..32).map_while(|i| r.u16(40 + 2 * i)).take_while(|u| *u != 0).collect(); + let (family, _) = decode_utf16(&units, &[]); + let font = Font { + family: family.trim().to_string(), + height: height as f32, + width: width as f32, + weight: weight.clamp(0, 1000) as u16, + italic: byte(32) != 0, + underline: byte(33) != 0, + strike: byte(34) != 0, + charset: byte(35), + }; + cv.add_object(Some(slot), Obj::Font(Arc::new(LogFont { font, escapement: f64::from(escapement) }))); + Some(()) +} + +/// The dx array at `off` for `n` characters: i32 advances, or (dx, dy) pairs under ETO_PDY (only +/// dx is used). Empty when absent or short. +fn dx_array(r: Bytes, off: usize, n: usize, opts: u32) -> Vec { + let step = if opts & ETO_PDY != 0 { 8 } else { 4 }; + if off == 0 { + return Vec::new(); + } + let dx: Vec = (0..n).map_while(|i| r.i32(off.checked_add(i.checked_mul(step)?)?)).map(|v| v as f32).collect(); + if dx.len() == n { dx } else { Vec::new() } +} + +/// EMR_EXTTEXTOUTA (83) and EMR_EXTTEXTOUTW (84): bounds, graphics mode and scales, then an EmrText +/// at 36: reference point, character count, string offset, options, the rectangle (absent under +/// ETO_NO_RECT) and the dx offset. Offsets count from the record start. Glyph-index strings name +/// glyphs of a font the reader cannot match, so they are left out. +fn ext_text_out(cv: &mut Canvas, r: Bytes, wide: bool) -> Option<()> { + let at = xy32(r, 36)?; + let n = (r.u32(44)? as usize).min(MAX_CHARS); + let off = r.u32(48)? as usize; + let opts = r.u32(52)?; + let (rect, dx_off) = if opts & ETO_NO_RECT != 0 { (None, r.u32(56)?) } else { (rect32(r, 56), r.u32(72)?) }; + if opts & ETO_GLYPH_INDEX != 0 { + return None; + } + let dx = dx_array(r, dx_off as usize, n, opts); + let (text, dx) = if wide { + let raw = r.slice(off, n.checked_mul(2)?)?; + let units: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + decode_utf16(&units, &dx) + } else { + decode_bytes(r.slice(off, n)?, cv.font_charset(), &dx) + }; + cv.text(at, text, dx, opts, rect); + Some(()) +} + +/// EMR_SMALLTEXTOUT (MS-EMF 2.3.5.37): x, y, character count, options, graphics mode, scales, the +/// bounds (absent under ETO_NO_RECT), then the characters: 8-bit under ETO_SMALL_CHARS, else UTF-16. +fn small_text_out(cv: &mut Canvas, r: Bytes) -> Option<()> { + let at = xy32(r, 8)?; + let n = (r.u32(16)? as usize).min(MAX_CHARS); + let opts = r.u32(20)?; + if opts & ETO_GLYPH_INDEX != 0 { + return None; + } + let (rect, off) = if opts & ETO_NO_RECT != 0 { (None, 36) } else { (rect32(r, 36), 52) }; + let (text, _) = if opts & ETO_SMALL_CHARS != 0 { + decode_bytes(r.slice(off, n)?, cv.font_charset(), &[]) + } else { + let raw = r.slice(off, n.checked_mul(2)?)?; + let units: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + decode_utf16(&units, &[]) + }; + cv.text(at, text, Vec::new(), opts, rect); Some(()) } diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index 22002351..d7d98501 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -1,6 +1,9 @@ //! DeckCraft metafiles (ported from WordCraft's `crates/metafile`): reads Windows Metafiles (WMF) -//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths and RGBA -//! bitmaps, in a picture frame with y down. +//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths, pattern +//! fills, RGBA bitmaps and text strings, in a picture frame with y down. +//! +//! Text stays font-agnostic: a [`Text`] item names the font the file asks for and carries the +//! string, its advances and alignment; the renderer resolves the font and draws the glyphs. //! //! The reader is pure Rust over byte slices (no file system), so it builds for wasm. Every read is //! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`], @@ -16,15 +19,20 @@ mod dib; mod emf; mod geom; mod place; +mod text; mod wmf; #[cfg(test)] mod tests; +use std::collections::HashMap; +use std::sync::Arc; + use bytes::Bytes; use canvas::Raw; use geom::{Op, P}; pub use place::{MAX_BITMAP_SIDE, PlacedItem}; +pub use text::{Font, HAlign, SYMBOL_CHARSET, Text, VAlign}; /// Items kept from one file. Past the cap the parse stops and keeps what it has. pub(crate) const MAX_ITEMS: usize = 200_000; @@ -61,6 +69,22 @@ pub enum Item { }, /// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down. Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec }, + /// A path filled with a repeating tile (pattern and hatch brushes). Items filled with the same + /// brush share its tile. + Pattern { segs: Vec, even_odd: bool, tile: Arc }, + /// A text string. + Text(Box), +} + +/// A brush tile: RGBA8 rows top-down (transparent where the brush leaves the background), each +/// pixel `cell` picture units, with a tile's top-left corner at `origin`. +#[derive(Debug, Clone, PartialEq)] +pub struct Tile { + pub width: u32, + pub height: u32, + pub rgba: Vec, + pub cell: [f32; 2], + pub origin: [f32; 2], } #[derive(Debug, Clone, Copy, PartialEq)] @@ -94,6 +118,8 @@ pub fn parse(bytes: &[u8]) -> Result { let (cv, frame) = if emf::is_emf(b) { emf::parse(b)? } else if wmf::is_wmf(b) { + // A WMF converted from an EMF can carry the original in MFCOMMENT escapes; PowerPoint draws + // the WMF's own records (its dx rounding shows), so the copy is not used. wmf::parse(b)? } else { return Err(Error::NotMetafile); @@ -124,6 +150,8 @@ fn build(raw: Vec, frame: Option) -> Picture { let stroke_scale = (sx + sy) / 2.0; // No stroke is wider than the picture's diagonal: anything wider is a mapping artefact. let diag = size.0.hypot(size.1); + // Tiles shared by several fills stay shared. + let mut tiles: HashMap<*const canvas::RawTile, (Arc, Arc)> = HashMap::new(); let items = raw.into_iter().filter_map(|r| match r { Raw::Path { ops, fill, stroke, even_odd } => { let segs = ops.iter().map(|op| seg(op, tf)).collect::>>()?; @@ -138,6 +166,44 @@ fn build(raw: Vec, frame: Option) -> Picture { let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?]; Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px }) } + Raw::Pattern { ops, even_odd, tile } => { + let segs = ops.iter().map(|op| seg(op, tf)).collect::>>()?; + if segs.len() < 2 { + return None; + } + let key = Arc::as_ptr(&tile); + let tile = match tiles.get(&key) { + Some((_, t)) => t.clone(), + None => { + let (ox, oy) = tf((0.0, 0.0)); + let t = Arc::new(Tile { + width: u32::try_from(tile.w).ok()?, + height: u32::try_from(tile.h).ok()?, + rgba: tile.px.clone(), + cell: [f(tile.cell * sx)?, f(tile.cell * sy)?], + origin: [f(ox)?, f(oy)?], + }); + // The raw tile is kept with its key, so the address is never reused by another. + tiles.insert(key, (tile.clone(), t.clone())); + t + } + }; + Some(Item::Pattern { segs, even_odd, tile }) + } + Raw::Text(mut t) => { + // Device to picture: scale by (sx, sy) after moving the frame's corner to the origin. + let [a, b, c, d, e, g] = t.transform; + let (e, g) = tf((e, g)); + t.transform = [a * sx, b * sy, c * sx, d * sy, e, g]; + if !t.transform.iter().all(|v| v.is_finite()) { + return None; + } + if let Some([x0, y0, x1, y1]) = t.clip { + let (p, q) = (tf((f64::from(x0), f64::from(y0))), tf((f64::from(x1), f64::from(y1)))); + t.clip = Some([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]); + } + Some(Item::Text(t)) + } }); let items = items.collect(); Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items } @@ -185,7 +251,7 @@ fn bbox(raw: &[Raw]) -> Option<[f64; 4]> { }; for r in raw { match r { - Raw::Path { ops, .. } => { + Raw::Path { ops, .. } | Raw::Pattern { ops, .. } => { for op in ops { match *op { Op::M(p) | Op::L(p) => grow(p), @@ -202,6 +268,7 @@ fn bbox(raw: &[Raw]) -> Option<[f64; 4]> { grow((rect[0], rect[1])); grow((rect[0] + rect[2], rect[1] + rect[3])); } + Raw::Text(t) => grow((t.transform[4], t.transform[5])), } } b diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs index 68f07955..28e0b6ae 100644 --- a/crates/metafile/src/place.rs +++ b/crates/metafile/src/place.rs @@ -19,6 +19,12 @@ pub enum PlacedItem { Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool }, /// `Picture::items[index]`, a bitmap, stretched into `rect`. Bitmap { index: usize, rect: Rect }, + /// `Picture::items[index]`, a pattern fill: `path` (target units) filled with the item's tile + /// repeated; `tile` maps tile pixels to target units. + Pattern { index: usize, path: BezPath, even_odd: bool, tile: Affine }, + /// `Picture::items[index]`, a text string: `transform` maps its text space to target units; + /// `clip` (target units) bounds what it draws. + Text { index: usize, transform: Affine, clip: Option }, } impl Picture { @@ -56,6 +62,26 @@ impl Picture { out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd }); } } + Item::Pattern { segs, even_odd, tile: t } => { + let path = tf * bez(segs); + let tile = tf + * Affine::translate((f64::from(t.origin[0]), f64::from(t.origin[1]))) + * Affine::scale_non_uniform(f64::from(t.cell[0]), f64::from(t.cell[1])); + let det = tile.determinant(); + if in_range(path.bounding_box()) && det.is_finite() && det.abs() > 1e-12 { + out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile }); + } + } + Item::Text(t) => { + let transform = tf * Affine::new(t.transform); + let clip = + t.clip.map(|[x0, y0, x1, y1]| tf.transform_rect_bbox(Rect::new(f64::from(x0), f64::from(y0), f64::from(x1), f64::from(y1)))); + let c = transform.as_coeffs(); + let origin = Rect::new(c[4], c[5], c[4], c[5]); + if in_range(origin) && c.iter().all(|v| v.is_finite()) && clip.is_none_or(in_range) { + out.push(PlacedItem::Text { index, transform, clip }); + } + } Item::Bitmap { rect: [x, y, w, h], .. } => { let (x0, y0) = (f64::from(*x), f64::from(*y)); let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h))); diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 4ee32578..271cc83b 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -120,7 +120,7 @@ const DIB24_RGBA: [u8; 16] = [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255 fn path_segs(item: &Item) -> (&Vec, Option<[u8; 4]>, Option<([u8; 4], f32)>, bool) { match item { Item::Path { segs, fill, stroke, even_odd } => (segs, *fill, *stroke, *even_odd), - Item::Bitmap { .. } => panic!("expected a path"), + other => panic!("expected a path: {other:?}"), } } @@ -1016,3 +1016,505 @@ fn plain_wmf_target_comes_from_a_window_that_was_read() { let (x0, y0, x1, y1) = rect_extent(&p.items[0]); assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}"); } + +// ---- Text, pattern and hatch brushes, mix-mode idioms ---- + +use crate::{HAlign, Picture, Text, VAlign}; + +/// META_CREATEFONTINDIRECT: height, width, escapement (and orientation), weight, the italic, +/// underline, strike-out and charset bytes, four precision bytes, then the NUL-terminated face. +fn wfont(height: i32, weight: i32, flags: [u8; 3], charset: u8, esc: i32, face: &[u8]) -> Vec { + let mut v = cat(vec![h16(height), h16(0), h16(esc), h16(esc), h16(weight)]); + v.extend(flags); + v.extend([charset, 0, 0, 0, 0]); + v.extend(face); + v.push(0); + wrec(0x02FB, &v) +} + +/// META_EXTTEXTOUT at (x, y): the rectangle when given, the string padded to a word, then `dx`. +fn wtext(x: i32, y: i32, s: &[u8], opts: i32, rect: Option<[i32; 4]>, dx: &[i32]) -> Vec { + let mut v = cat(vec![h16(y), h16(x), h16(s.len() as i32), h16(opts)]); + for c in rect.into_iter().flatten() { + v.extend(h16(c)); + } + v.extend(s); + if s.len() % 2 == 1 { + v.push(0); + } + for d in dx { + v.extend(h16(*d)); + } + wrec(0x0A32, &v) +} + +/// A plain WMF whose 200 × 100 window maps one logical unit to one picture unit. +fn wmf200(records: &[Vec]) -> Picture { + let mut recs = vec![wrec(0x020C, &cat(vec![h16(100), h16(200)]))]; + recs.extend(records.iter().cloned()); + parse(&wmf(&recs, None)).unwrap() +} + +fn texts(p: &Picture) -> Vec<&Text> { + p.items + .iter() + .filter_map(|i| match i { + Item::Text(t) => Some(t.as_ref()), + _ => None, + }) + .collect() +} + +fn close6(a: [f64; 6], b: [f64; 6]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) +} + +#[test] +fn wmf_text_reads_the_font_string_dx_colour_and_alignment() { + let recs = [ + wfont(-20, 700, [1, 1, 1], 0, 0, b"Arial"), + wrec(0x012D, &h16(0)), + wrec(0x0209, &h32(0x0000_00FF)), + wrec(0x0102, &h16(1)), + wrec(0x012E, &h16(24 | 6)), + wtext(50, 60, b"Hi!", 0, None, &[10, 8, 6]), + ]; + let p = wmf200(&recs); + let t = texts(&p); + assert_eq!(t.len(), 1, "{:?}", p.items); + let t = t[0]; + assert_eq!(t.text, "Hi!"); + assert_eq!(t.dx, vec![10.0, 8.0, 6.0], "the dx array follows the word-padded odd string"); + assert_eq!(t.font.family, "Arial"); + assert_eq!((t.font.height, t.font.weight, t.font.italic, t.font.underline, t.font.strike), (-20.0, 700, true, true, true)); + assert_eq!(t.color, [255, 0, 0, 255]); + assert_eq!(t.background, None, "TRANSPARENT background mode"); + assert_eq!((t.h_align, t.v_align), (HAlign::Center, VAlign::Baseline)); + assert!(close6(t.transform, [1.0, 0.0, 0.0, 1.0, 50.0, 60.0]), "{:?}", t.transform); + assert_eq!(t.clip, None); +} + +#[test] +fn font_height_sign_and_default_state_are_kept() { + // A positive height is the cell height; it stays positive for the renderer to resolve. Without + // SETTEXTALIGN the reference point is the top left; without SETBKMODE the cell is filled white. + let recs = [wfont(30, 400, [0, 0, 0], 0, 0, b"Times New Roman"), wrec(0x012D, &h16(0)), wtext(5, 5, b"x", 0, None, &[])]; + let p = wmf200(&recs); + let t = texts(&p)[0]; + assert_eq!(t.font.height, 30.0); + assert_eq!(t.font.family, "Times New Roman"); + assert_eq!((t.h_align, t.v_align), (HAlign::Left, VAlign::Top)); + assert_eq!(t.background, Some([255, 255, 255, 255])); + assert!(t.dx.is_empty(), "no dx array in the record"); + // Right and bottom. + let p = wmf200(&[wrec(0x012E, &h16(2 | 8)), wtext(5, 5, b"x", 0, None, &[])]); + let t = texts(&p)[0]; + assert_eq!((t.h_align, t.v_align), (HAlign::Right, VAlign::Bottom)); + assert_eq!(t.font, crate::Font::default(), "no font selected"); +} + +#[test] +fn escapement_turns_the_baseline_counter_clockwise() { + let recs = [wfont(-20, 400, [0, 0, 0], 0, 900, b"Arial"), wrec(0x012D, &h16(0)), wtext(100, 90, b"Up", 0, None, &[])]; + let p = wmf200(&recs); + let t = texts(&p)[0]; + // Text x runs up the picture (0, -1); text y (down the glyphs) runs right (1, 0). + assert!(close6(t.transform, [0.0, -1.0, 1.0, 0.0, 100.0, 90.0]), "{:?}", t.transform); +} + +#[test] +fn a_y_up_mapping_moves_text_but_does_not_mirror_it() { + // Window 200 × -100 onto a 200 × 100 viewport: y goes up. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020C, &cat(vec![h16(-100), h16(200)])), + wrec(0x020E, &cat(vec![h16(100), h16(200)])), + wtext(40, -30, b"A", 0, None, &[]), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + let t = texts(&p)[0]; + assert!(close6(t.transform, [1.0, 0.0, 0.0, 1.0, 40.0, 30.0]), "{:?}", t.transform); +} + +#[test] +fn update_cp_starts_at_the_current_position_and_moves_past_the_string() { + let recs = + [wrec(0x012E, &h16(1)), wrec(0x0214, &cat(vec![h16(20), h16(10)])), wtext(0, 0, b"ab", 0, None, &[5, 7]), wtext(0, 0, b"c", 0, None, &[3])]; + let p = wmf200(&recs); + let t = texts(&p); + assert_eq!(t.len(), 2); + assert!(close6(t[0].transform, [1.0, 0.0, 0.0, 1.0, 10.0, 20.0]), "{:?}", t[0].transform); + assert!(close6(t[1].transform, [1.0, 0.0, 0.0, 1.0, 22.0, 20.0]), "{:?}", t[1].transform); +} + +#[test] +fn eto_opaque_fills_the_rectangle_and_eto_clipped_clips() { + let recs = [wrec(0x0201, &h32(0x0000_FF00)), wtext(12, 30, b"ok", 2 | 4, Some([10, 10, 60, 40]), &[])]; + let p = wmf200(&recs); + assert_eq!(p.items.len(), 2, "{:?}", p.items); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 10.0) && close(y0, 10.0) && close(x1, 60.0) && close(y1, 40.0)); + assert_eq!(path_segs(&p.items[0]).1, Some([0, 255, 0, 255])); + let t = texts(&p)[0]; + assert_eq!(t.clip, Some([10.0, 10.0, 60.0, 40.0])); + assert_eq!(t.background, Some([0, 255, 0, 255])); +} + +#[test] +fn character_sets_decode_bytes() { + let one = |charset: u8, s: &[u8]| { + let p = wmf200(&[wfont(-10, 400, [0, 0, 0], charset, 0, b"F"), wrec(0x012D, &h16(0)), wtext(0, 0, s, 0, None, &[])]); + texts(&p)[0].text.clone() + }; + assert_eq!(one(0, &[b'A', 0x80, 0x93, 0x94, 0xE9]), "A€“”é"); + assert_eq!(one(238, &[0xB9, 0xE8, 0x9E, 0xA3]), "ąčžŁ"); + assert_eq!(one(204, &[0xC0, 0xFF, 0xA8]), "АяЁ"); + assert_eq!(one(2, b"n\xA7"), "\u{F06E}\u{F0A7}", "symbol fonts keep their codes in the private-use area"); + assert_eq!(one(0, &[0x81]), "\u{FFFD}", "an undefined byte"); + // A double-byte character set: a lead and trail byte become one blank with both advances. + let p = + wmf200(&[wfont(-10, 400, [0, 0, 0], 128, 0, b"F"), wrec(0x012D, &h16(0)), wtext(0, 0, &[0x41, 0x82, 0xA0, 0x42], 0, None, &[1, 2, 3, 4])]); + let t = texts(&p)[0]; + assert_eq!((t.text.as_str(), t.dx.as_slice()), ("A B", [1.0, 5.0, 4.0].as_slice())); +} + +#[test] +fn wmf_textout_reads_its_string_and_position() { + let mut v = cat(vec![h16(3)]); + v.extend(b"abc\0"); + v.extend(cat(vec![h16(40), h16(30)])); + let p = wmf200(&[wrec(0x0521, &v)]); + let t = texts(&p)[0]; + assert_eq!(t.text, "abc"); + assert!(close6(t.transform, [1.0, 0.0, 0.0, 1.0, 30.0, 40.0]), "{:?}", t.transform); +} + +#[test] +fn savedc_restores_text_colour_and_font() { + let recs = [ + wfont(-10, 400, [0, 0, 0], 0, 0, b"One"), + wrec(0x012D, &h16(0)), + wrec(0x0209, &h32(0x0000_00FF)), + wrec(0x001E, &[]), + wfont(-10, 400, [0, 0, 0], 0, 0, b"Two"), + wrec(0x012D, &h16(1)), + wrec(0x0209, &h32(0x00FF_0000)), + wrec(0x0127, &h16(-1)), + wtext(0, 0, b"x", 0, None, &[]), + ]; + let p = wmf200(&recs); + let t = texts(&p)[0]; + assert_eq!((t.font.family.as_str(), t.color), ("One", [255, 0, 0, 255])); +} + +/// EMR_EXTCREATEFONTINDIRECTW in slot 1: LogFont at 12, face at 40. +fn efont(height: i32, esc: i32, weight: i32, italic: u8, charset: u8, face: &str) -> Vec { + let mut v = cat(vec![h32(1), e32(height), e32(0), e32(esc), e32(esc), e32(weight)]); + v.extend([italic, 0, 0, charset, 0, 0, 0, 0]); + let mut name: Vec = face.encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + name.resize(64, 0); + v.extend(name); + erec(82, &v) +} + +/// EMR_EXTTEXTOUTW (or A) at (x, y) with options, the rectangle (absent under ETO_NO_RECT), the +/// string at 76 and the dx array after it. +fn etext(wide: bool, x: i32, y: i32, units: &[u16], opts: u32, dx: &[i32]) -> Vec { + let no_rect = opts & 0x100 != 0; + let fixed = if no_rect { 60 } else { 76 }; + let s: Vec = if wide { units.iter().flat_map(|u| u.to_le_bytes()).collect() } else { units.iter().map(|u| *u as u8).collect() }; + let s_off = fixed; + let dx_off = (s_off + s.len()).next_multiple_of(4); + let len = dx_off + dx.len() * 4; + let mut f = vec![(24, 1u32), (36, x as u32), (40, y as u32), (44, units.len() as u32), (48, s_off as u32), (52, opts)]; + f.push((if no_rect { 56 } else { 72 }, if dx.is_empty() { 0 } else { dx_off as u32 })); + let dxb: Vec = dx.iter().flat_map(|d| d.to_le_bytes()).collect(); + erec_at(if wide { 84 } else { 83 }, len.next_multiple_of(4), &f, &[(s_off, &s), (dx_off, &dxb)]) +} + +#[test] +fn emf_exttextoutw_reads_utf16_dx_and_the_font() { + let recs = [ + efont(24, 0, 400, 1, 0, "Segoe UI"), + erec(37, &h32(1)), + erec(22, &h32(0x18 | 2)), + erec(24, &h32(0x0000_FF00)), + erec(18, &h32(1)), + etext(true, 100, 50, &[0x41, 0xD83D, 0xDE00], 0, &[10, 6, 0]), + ]; + let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + let t = texts(&p); + assert_eq!(t.len(), 1, "{:?}", p.items); + let t = t[0]; + assert_eq!(t.text, "A\u{1F600}"); + assert_eq!(t.dx, vec![10.0, 6.0], "a surrogate pair takes both advances"); + assert_eq!((t.font.family.as_str(), t.font.height, t.font.italic), ("Segoe UI", 24.0, true)); + assert_eq!((t.h_align, t.v_align, t.color, t.background), (HAlign::Right, VAlign::Baseline, [0, 255, 0, 255], None)); + // Upright, uniformly scaled. + let [a, b, c, d, ..] = t.transform; + assert!(a > 0.0 && d > 0.0 && b.abs() < 1e-9 && c.abs() < 1e-9 && (a - d).abs() < 1e-9, "{:?}", t.transform); +} + +#[test] +fn emf_text_options_pdy_no_rect_ansi_and_glyph_indices() { + // ETO_PDY: (dx, dy) pairs, of which dx is used. ETO_NO_RECT moves the dx offset to 56. + let recs = [etext(true, 0, 0, &[0x61, 0x62], 0x2000 | 0x100, &[7, 1, 9, 2])]; + let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + assert_eq!(texts(&p)[0].dx, vec![7.0, 9.0]); + // EXTTEXTOUTA decodes through the font's charset. + let recs = [efont(-10, 0, 400, 0, 238, "F"), erec(37, &h32(1)), etext(false, 0, 0, &[0xB9], 0, &[])]; + let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + assert_eq!(texts(&p)[0].text, "ą"); + // Glyph indices name glyphs of a font we cannot match: left out. + let recs = [etext(true, 0, 0, &[5, 6], 0x10, &[])]; + let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + assert!(texts(&p).is_empty()); +} + +#[test] +fn emf_smalltextout_reads_8_bit_and_16_bit_strings() { + // x, y, count, options, mode, scales, (no bounds under ETO_NO_RECT), characters. + let small = |opts: u32, chars: &[u8]| { + let mut v = cat(vec![e32(3), e32(4), h32(2), h32(opts), h32(1), h32(0), h32(0)]); + v.extend(chars); + erec(108, &v) + }; + let p = parse(&emf(&[small(0x100 | 0x200, b"hi")], [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + assert_eq!(texts(&p)[0].text, "hi"); + let p = parse(&emf(&[small(0x100, &[b'o', 0, b'k', 0])], [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + assert_eq!(texts(&p)[0].text, "ok"); +} + +/// An 8 × 8 one-bit DIB (palette white, black; stored bottom-up): `rows` top to bottom. +fn mono_dib(rows: [u8; 8]) -> Vec { + let mut v = info_header(8, 8, 1, 32); + v.extend([255, 255, 255, 0, 0, 0, 0, 0]); + for r in rows.iter().rev() { + v.extend([*r, 0, 0, 0]); + } + v +} + +fn pattern_of(item: &Item) -> &crate::Tile { + match item { + Item::Pattern { tile, .. } => tile, + other => panic!("expected a pattern fill: {other:?}"), + } +} + +/// Pixel (x, y) of a tile. +fn tile_px(t: &crate::Tile, x: usize, y: usize) -> [u8; 4] { + let o = (y * t.width as usize + x) * 4; + t.rgba[o..o + 4].try_into().unwrap() +} + +#[test] +fn wmf_dib_pattern_brush_paints_its_rows_top_down() { + let brush = wrec(0x0142, &cat(vec![h16(5), h16(0), mono_dib([0xFF, 0x80, 0, 0, 0, 0, 0, 0x01])])); + let recs = [ + brush, + // A pen after the brush: the brush took handle 0. + wrec(0x02FA, &cat(vec![h16(0), h16(1), h16(0), h32(0x0000_00FF)])), + wrec(0x012D, &h16(0)), + wrec(0x012D, &h16(1)), + wrec(0x041B, &cat(vec![h16(720), h16(1440), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, Some([0, 0, 1440, 720]))).unwrap(); + assert_eq!(p.items.len(), 2, "the tile fill and the outline: {:?}", p.items); + let t = pattern_of(&p.items[0]); + assert_eq!((t.width, t.height), (8, 8)); + for x in 0..8 { + assert_eq!(tile_px(t, x, 0), [0, 0, 0, 255], "top row is set: black"); + } + assert_eq!(tile_px(t, 0, 1), [0, 0, 0, 255]); + assert_eq!(tile_px(t, 1, 1), [255, 255, 255, 255]); + assert_eq!(tile_px(t, 7, 7), [0, 0, 0, 255]); + assert_eq!(tile_px(t, 0, 7), [255, 255, 255, 255]); + // 96-dpi pixels of a picture in points, laid from the picture's corner. + assert!(close(t.cell[0], 0.75) && close(t.cell[1], 0.75) && t.origin == [0.0, 0.0], "{:?} {:?}", t.cell, t.origin); + assert_eq!(path_segs(&p.items[1]).2.map(|s| s.0), Some([255, 0, 0, 255])); +} + +#[test] +fn hatch_brushes_draw_lines_over_the_background_mode() { + let hatch = |style: i32| wrec(0x02FC, &cat(vec![h16(2), h32(0x00FF_0000), h16(style)])); + let rect = wrec(0x041B, &cat(vec![h16(50), h16(50), h16(0), h16(0)])); + // HS_CROSS over a transparent background. + let p = wmf200(&[hatch(4), wrec(0x012D, &h16(0)), wrec(0x0102, &h16(1)), rect.clone()]); + let t = pattern_of(&p.items[0]); + let blue = [0, 0, 255, 255]; + assert_eq!((tile_px(t, 3, 7), tile_px(t, 7, 3)), (blue, blue)); + assert_eq!(tile_px(t, 3, 3)[3], 0, "gaps are transparent"); + // HS_BDIAGONAL over the opaque white background. + let p = wmf200(&[hatch(3), wrec(0x012D, &h16(0)), rect.clone()]); + let t = pattern_of(&p.items[0]); + assert_eq!((tile_px(t, 0, 7), tile_px(t, 7, 0)), (blue, blue)); + assert_eq!(tile_px(t, 0, 0), [255, 255, 255, 255]); + // An unknown hatch style draws no fill. + let p = wmf200(&[hatch(9), wrec(0x012D, &h16(0)), rect]); + assert!(p.items.iter().all(|i| !matches!(i, Item::Pattern { .. })), "{:?}", p.items); +} + +#[test] +fn xor_mask_xor_draws_the_pattern_in_the_xor_colour() { + // The see-through shadow: XOR a grey polygon, AND a checker pattern, XOR the polygon again. + let square = wrec(0x0324, &cat(vec![h16(4), h16(0), h16(0), h16(40), h16(0), h16(40), h16(40), h16(0), h16(40)])); + let recs = [ + wrec(0x0104, &h16(7)), + wrec(0x02FC, &cat(vec![h16(0), h32(0x0080_8080), h16(0)])), + wrec(0x012D, &h16(0)), + square.clone(), + wrec(0x0104, &h16(9)), + wrec(0x0142, &cat(vec![h16(5), h16(0), mono_dib([0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA])])), + wrec(0x012D, &h16(1)), + wrec(0x041B, &cat(vec![h16(40), h16(40), h16(0), h16(0)])), + wrec(0x0104, &h16(7)), + wrec(0x012D, &h16(0)), + square, + ]; + let p = wmf200(&recs); + assert_eq!(p.items.len(), 2, "only the AND pattern (and its outline) draw: {:?}", p.items); + let t = pattern_of(&p.items[0]); + // 0x55 = 0101 0101: white, black, … + assert_eq!(tile_px(t, 0, 0)[3], 0, "white keeps what is below"); + assert_eq!(tile_px(t, 1, 0), [128, 128, 128, 255], "black takes the XOR colour"); +} + +#[test] +fn patblt_fills_with_the_brush() { + let patblt = |rop: u32| wrec(0x061D, &cat(vec![h32(rop), h16(30), h16(40), h16(10), h16(20)])); + let recs = [wrec(0x02FC, &cat(vec![h16(0), h32(0x00FF_0000), h16(0)])), wrec(0x012D, &h16(0)), patblt(0x00F0_0021), patblt(0x0055_0009)]; + let p = wmf200(&recs); + assert_eq!(p.items.len(), 1, "DSTINVERT draws nothing: {:?}", p.items); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 20.0) && close(y0, 10.0) && close(x1, 60.0) && close(y1, 40.0), "{x0} {y0} {x1} {y1}"); + assert_eq!(path_segs(&p.items[0]).1, Some([0, 0, 255, 255])); +} + +#[test] +fn emf_dib_pattern_and_mono_brushes() { + let dib = mono_dib([0xF0; 8]); + let (info, bits) = dib.split_at(48); + let brush = |typ: u32| { + let f = [(8, 1u32), (12, 0), (16, 32), (20, info.len() as u32), (24, 32 + info.len() as u32), (28, bits.len() as u32)]; + erec_at(typ, 32 + dib.len(), &f, &[(32, info), (32 + info.len(), bits)]) + }; + let rect = erec(43, &cat(vec![e32(0), e32(0), e32(40), e32(40)])); + let null_pen = erec(37, &h32(0x8000_0008)); + let p = parse(&emf(&[brush(94), erec(37, &h32(1)), null_pen.clone(), rect.clone()], [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + let t = pattern_of(&p.items[0]); + assert_eq!((tile_px(t, 0, 0), tile_px(t, 7, 0)), ([0, 0, 0, 255], [255, 255, 255, 255])); + // A mono brush paints in the text colour (red) and the background colour (green). + let recs = [erec(24, &h32(0xFF)), erec(25, &h32(0xFF00)), brush(93), erec(37, &h32(1)), null_pen, rect]; + let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + let t = pattern_of(&p.items[0]); + assert_eq!((tile_px(t, 0, 0), tile_px(t, 7, 0)), ([255, 0, 0, 255], [0, 255, 0, 255])); +} + +#[test] +fn hostile_text_and_brush_records_never_panic() { + // A string longer than its record is cut to what the record holds. + let mut v = cat(vec![h16(0), h16(0), h16(0x7FFF), h16(0)]); + v.extend(b"abcd"); + let p = wmf200(&[wrec(0x0A32, &v)]); + assert_eq!(texts(&p)[0].text, "abcd"); + // The rectangle flag without room for the rectangle draws nothing. + let p = wmf200(&[wrec(0x0A32, &cat(vec![h16(0), h16(0), h16(1), h16(6)]))]); + assert!(texts(&p).is_empty()); + // A face name with no terminator stops at 32 bytes. + let p = wmf200(&[wfont(-10, 400, [0, 0, 0], 0, 0, &[b'A'; 40]), wrec(0x012D, &h16(0)), wtext(0, 0, b"x", 0, None, &[])]); + assert_eq!(texts(&p)[0].font.family.len(), 32); + // A pattern brush whose DIB is cut short, or too big for a tile, still takes its handle slot + // and draws no fill. + let short = wrec(0x0142, &cat(vec![h16(5), h16(0), info_header(8, 8, 1, 32)])); + let mut huge = info_header(300, 300, 1, 0); + huge.extend([255, 255, 255, 0, 0, 0, 0, 0]); + huge.extend(vec![0u8; 40 * 300]); + let huge = wrec(0x0142, &cat(vec![h16(5), h16(0), huge])); + for b in [short, huge] { + let recs = [ + b, + wrec(0x02FA, &cat(vec![h16(0), h16(1), h16(0), h32(0xFF)])), + wrec(0x012D, &h16(0)), + wrec(0x012D, &h16(1)), + wrec(0x041B, &cat(vec![h16(9), h16(9), h16(0), h16(0)])), + ]; + let p = wmf200(&recs); + assert_eq!(p.items.len(), 1, "{:?}", p.items); + let (_, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!((fill, stroke.map(|s| s.0)), (Some([255, 255, 255, 255]), Some([255, 0, 0, 255]))); + } + // EMF: the string offset past the record, a huge count, a dx offset past the record. + let bad = [ + erec_at(84, 80, &[(44, 2), (48, 4000), (52, 0)], &[]), + erec_at(84, 84, &[(44, u32::MAX), (48, 76), (52, 0)], &[(76, &[b'a', 0, b'b', 0])]), + erec_at(84, 80, &[(44, 2), (48, 76), (52, 0), (72, 0xFFFF_FFF0)], &[(76, &[b'a', 0, b'b', 0])]), + erec_at(82, 20, &[(8, 1)], &[]), + erec_at(94, 32, &[(8, 1), (16, 0xFFFF_FFFF), (20, 40), (24, 0), (28, 0xFFFF_FFFF)], &[]), + erec_at(108, 40, &[(16, u32::MAX), (20, 0x300)], &[]), + ]; + for r in bad { + let _ = parse(&emf(&[r], [0, 0, 200, 100], [0, 0, 5000, 2500])); + } + let p = parse(&emf( + &[erec_at(84, 84, &[(44, 2), (48, 76), (52, 0), (72, 0xFFFF_FFF0)], &[(76, &[b'a', 0, b'b', 0])])], + [0, 0, 200, 100], + [0, 0, 5000, 2500], + )) + .unwrap(); + assert!(texts(&p)[0].dx.is_empty(), "a dx array past the record is dropped"); + // Mutated text and brush files. + let wfile = wmf( + &[ + wfont(-20, 700, [1, 1, 1], 238, 450, b"Arial"), + wrec(0x012D, &h16(0)), + wrec(0x0142, &cat(vec![h16(5), h16(0), mono_dib([0x55; 8])])), + wrec(0x012D, &h16(1)), + wrec(0x012E, &h16(1)), + wtext(5, 5, b"Hello", 6, Some([0, 0, 50, 50]), &[1, 2, 3, 4, 5]), + wrec(0x041B, &cat(vec![h16(9), h16(9), h16(0), h16(0)])), + ], + Some([0, 0, 100, 100]), + ); + let efile = emf( + &[efont(24, 900, 400, 1, 0, "Segoe UI"), erec(37, &h32(1)), etext(true, 1, 2, &[0x41, 0xD83D, 0xDE00], 0x2000, &[1, 2, 3, 4, 5, 6])], + [0, 0, 200, 100], + [0, 0, 5000, 2500], + ); + let mut rng = Lcg(0x9E37_79B9_7F4A_7C15); + for file in [&wfile, &efile] { + for n in 0..=file.len() { + let _ = parse(&file[..n]); + } + for _ in 0..1500 { + let mut buf = file.clone(); + for _ in 0..6 { + let at = rng.rnd() as usize % buf.len(); + buf[at] = rng.rnd() as u8; + } + let _ = parse(&buf); + } + } +} + +#[test] +fn text_items_are_capped_like_any_other() { + let t = wtext(0, 0, b"x", 0, None, &[]); + let recs: Vec> = (0..MAX_ITEMS + 5).map(|_| t.clone()).collect(); + let p = wmf200(&recs); + assert_eq!(p.items.len(), MAX_ITEMS); +} + +#[test] +fn pattern_brush_pixels_count_against_the_byte_budget() { + // 256 × 256 one-bit pattern brushes (256 KiB of RGBA each): far fewer than the object table + // holds fit the budget, and the parse stops there. + let mut dib = info_header(256, 256, 1, 0); + dib.extend([255, 255, 255, 0, 0, 0, 0, 0]); + dib.extend(vec![0x55u8; 32 * 256]); + let brush = wrec(0x0142, &cat(vec![h16(5), h16(0), dib])); + let mut recs: Vec> = (0..1100).map(|_| brush.clone()).collect(); + recs.push(wrec(0x041B, &cat(vec![h16(9), h16(9), h16(0), h16(0)]))); + let p = wmf200(&recs); + assert!(p.items.is_empty(), "the parse stopped at the budget: {:?}", p.items.len()); +} diff --git a/crates/metafile/src/text.rs b/crates/metafile/src/text.rs new file mode 100644 index 00000000..86bca911 --- /dev/null +++ b/crates/metafile/src/text.rs @@ -0,0 +1,200 @@ +//! Text records: the font (LOGFONT), the text string decoded from its character set, and the +//! neutral [`Text`] item. The metafile crate knows no fonts, so measuring, alignment against the +//! font's metrics and glyph drawing are left to the renderer. + +/// A font chosen by a CREATEFONTINDIRECT record (MS-WMF 2.2.1.2 Font, MS-EMF 2.2.13 LogFont). +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Font { + /// The face name the file asks for (`"Arial"`, `"Wingdings"`); may be empty. + pub family: String, + /// LOGFONT height in text units: negative is the character (em) height, positive the cell + /// height (ascent plus descent), 0 the default size. + pub height: f32, + /// Average character width in text units; 0 keeps the font's own proportions. + pub width: f32, + /// 100..=900 (400 regular, 700 bold); 0 is the default weight. + pub weight: u16, + pub italic: bool, + pub underline: bool, + pub strike: bool, + /// LOGFONT character set (0 ANSI, 2 SYMBOL, 238 EASTEUROPE…). + pub charset: u8, +} + +/// SYMBOL_CHARSET: byte codes are the font's own symbol codes. +pub const SYMBOL_CHARSET: u8 = 2; + +/// Where the reference point sits on the string horizontally (TA_LEFT, TA_CENTER, TA_RIGHT). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum HAlign { + #[default] + Left, + Center, + Right, +} + +/// Where the reference point sits vertically (TA_TOP, TA_BASELINE, TA_BOTTOM): the top of the +/// character cell, the baseline or the bottom of the cell. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum VAlign { + #[default] + Top, + Baseline, + Bottom, +} + +/// One string drawn by EXTTEXTOUT, TEXTOUT or SMALLTEXTOUT. +#[derive(Debug, Clone, PartialEq)] +pub struct Text { + /// Text space to picture space as affine coefficients `[a, b, c, d, e, f]` (`x' = a·x + c·y + e`, + /// `y' = b·x + d·y + f`, kurbo's order). Text space has x along the baseline and y down, in the + /// metafile's logical units, with the reference point at the origin. Escapement and the + /// mapping are folded in; glyphs are never mirrored. + pub transform: [f64; 6], + pub text: String, + /// Advance after each character of `text`, in text units (the dx array). Empty when the file + /// gives none: the font's own advances apply. + pub dx: Vec, + pub font: Font, + pub color: [u8; 4], + /// Colour filled behind the character cells (OPAQUE background mode); None when transparent. + pub background: Option<[u8; 4]>, + pub h_align: HAlign, + pub v_align: VAlign, + /// Clip rectangle `[x0, y0, x1, y1]` in picture units (ETO_CLIPPED). + pub clip: Option<[f32; 4]>, +} + +/// TA_UPDATECP: the string starts at the current position, which moves past it. +pub(crate) const TA_UPDATECP: u32 = 0x0001; +/// ETO_OPAQUE fills the rectangle with the background colour; ETO_CLIPPED clips the text to it. +pub(crate) const ETO_OPAQUE: u32 = 0x0002; +pub(crate) const ETO_CLIPPED: u32 = 0x0004; +/// The string holds glyph indices, not characters (drawn only when the font matches). +pub(crate) const ETO_GLYPH_INDEX: u32 = 0x0010; +/// The dx array holds (dx, dy) pairs. +pub(crate) const ETO_PDY: u32 = 0x2000; +/// Longest string kept from one record. +pub(crate) const MAX_CHARS: usize = 16_384; + +/// Horizontal and vertical alignment from a TA_* word (the vertical bits are 0 top, 8 bottom, +/// 24 baseline; the horizontal ones 0 left, 2 right, 6 center). +pub(crate) fn align(ta: u32) -> (HAlign, VAlign) { + let h = match ta & 0x06 { + 6 => HAlign::Center, + 2 => HAlign::Right, + _ => HAlign::Left, + }; + let v = match ta & 0x18 { + 0x18 => VAlign::Baseline, + 0x08 => VAlign::Bottom, + _ => VAlign::Top, + }; + (h, v) +} + +/// Decodes 8-bit text in `charset` to one character per byte, and merges `dx` to match: a +/// double-byte character set's lead and trail bytes become one blank (its glyphs are not +/// decoded) whose advance is the sum of both. `dx` may be empty. +pub(crate) fn decode_bytes(bytes: &[u8], charset: u8, dx: &[f32]) -> (String, Vec) { + let mut s = String::with_capacity(bytes.len()); + let mut out_dx = Vec::with_capacity(dx.len().min(bytes.len())); + let dbcs = matches!(charset, 128 | 129 | 130 | 134 | 136); + let mut i = 0; + while let Some(&b) = bytes.get(i) { + let n = if dbcs && b >= 0x81 && i + 1 < bytes.len() { 2 } else { 1 }; + let c = if n == 2 { ' ' } else { byte_char(b, charset) }; + s.push(c); + if !dx.is_empty() { + out_dx.push((i..i + n).filter_map(|k| dx.get(k)).sum()); + } + i += n; + } + (s, out_dx) +} + +/// UTF-16 text to a string with one dx entry per character: a surrogate pair takes the sum of +/// its two advances. Unpaired surrogates become U+FFFD. +pub(crate) fn decode_utf16(units: &[u16], dx: &[f32]) -> (String, Vec) { + let mut s = String::with_capacity(units.len()); + let mut out_dx = Vec::with_capacity(dx.len().min(units.len())); + let mut i = 0; + for r in char::decode_utf16(units.iter().copied()) { + let c = r.unwrap_or(char::REPLACEMENT_CHARACTER); + let n = c.len_utf16(); + s.push(c); + if !dx.is_empty() { + out_dx.push((i..i + n).filter_map(|k| dx.get(k)).sum()); + } + i += n; + } + (s, out_dx) +} + +/// One byte in a single-byte Windows code page. Symbol fonts keep their codes in the private-use +/// range (U+F000 + code), where symbol fonts map them. +fn byte_char(b: u8, charset: u8) -> char { + if b < 0x80 && charset != SYMBOL_CHARSET { + return char::from(b); + } + let cp = match charset { + SYMBOL_CHARSET => 0xF000 | u32::from(b), + 238 => high(b, &CP1250), + 204 => high(b, &CP1251), + _ => high(b, &CP1252), + }; + char::from_u32(cp).unwrap_or(char::REPLACEMENT_CHARACTER) +} + +/// The code point of byte `b` (0x80..=0xFF) from a table of the upper half; 0 entries are +/// undefined bytes, drawn as U+FFFD. +fn high(b: u8, table: &[u16; 128]) -> u32 { + match table.get(usize::from(b.wrapping_sub(0x80))) { + Some(0) | None => 0xFFFD, + Some(v) => u32::from(*v), + } +} + +/// Windows-1252 (Western) 0x80..=0xFF: the upper half of Latin-1 with typographic marks in 0x80..=0x9F. +const CP1252: [u16; 128] = { + let mut t = [0u16; 128]; + let low: [u16; 32] = [ + 0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0, 0x017D, 0, 0, 0x2018, 0x2019, 0x201C, + 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178, + ]; + let mut i = 0; + while i < 128 { + t[i] = if i < 32 { low[i] } else { 0x80 + i as u16 }; + i += 1; + } + t +}; + +/// Windows-1250 (Central European) 0x80..=0xFF. +const CP1250: [u16; 128] = [ + 0x20AC, 0, 0x201A, 0, 0x201E, 0x2026, 0x2020, 0x2021, 0, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, // + 0, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, // + 0x00A0, 0x02C7, 0x02D8, 0x0141, 0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, // + 0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5, 0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, // + 0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7, 0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, // + 0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E, 0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, // + 0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB, 0x011B, 0x00ED, 0x00EE, 0x010F, // + 0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD, 0x0163, 0x02D9, +]; + +/// Windows-1251 (Cyrillic) 0x80..=0xFF; 0xC0..=0xFF are А..я in order. +const CP1251: [u16; 128] = { + let low: [u16; 64] = [ + 0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, // + 0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, // + 0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, // + 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457, + ]; + let mut t = [0u16; 128]; + let mut i = 0; + while i < 128 { + t[i] = if i < 64 { low[i] } else { 0x0410 + (i as u16 - 64) }; + i += 1; + } + t +}; diff --git a/crates/metafile/src/wmf.rs b/crates/metafile/src/wmf.rs index d221a765..12733e1c 100644 --- a/crates/metafile/src/wmf.rs +++ b/crates/metafile/src/wmf.rs @@ -1,14 +1,19 @@ //! WMF reader (MS-WMF). A file is an optional 22-byte placeable header, a 9-word standard header, then //! records of (size in 16-bit words, function, parameters). Parameters are read at byte 6 of each record. +use std::sync::Arc; + use crate::bytes::Bytes; -use crate::canvas::{Brush, Canvas, Obj, Pen, colorref}; +use crate::canvas::{BS_HATCHED, Brush, Canvas, LogFont, Obj, Pen, colorref}; use crate::dib; use crate::geom::{self, ArcKind, P}; +use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, MAX_CHARS, decode_bytes}; use crate::{Error, Frame, MAX_PATH_OPS}; const PLACEABLE_KEY: u32 = 0x9AC6_CDD7; const DEFAULT_INCH: f64 = 1440.0; +/// A placeable WMF plays into a target sized in points; brushes are laid out in 96-dpi pixels. +const PT_PER_PX: f64 = 72.0 / 96.0; pub(crate) fn is_wmf(b: Bytes) -> bool { if b.u32(0) == Some(PLACEABLE_KEY) { @@ -42,6 +47,7 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { let mut cv = Canvas::new(); if let Some((c, size)) = bounds { cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size); + cv.pattern_px = PT_PER_PX; } let mut player_viewport = bounds.is_some(); let mut parsed = 0usize; @@ -128,12 +134,31 @@ fn record(cv: &mut Canvas, func: u16, r: Bytes) { }), 0x02FA => create_pen(cv, r), 0x02FC => create_brush(cv, r), - // Fonts, palettes, pattern brushes and regions take a handle slot too, so later SELECTOBJECT - // handles count them; selecting one draws nothing. - 0x02FB | 0x00F7 | 0x01F9 | 0x0142 | 0x06FF => { + 0x02FB => { + let f = font(r).unwrap_or_default(); + cv.add_object(None, Obj::Font(Arc::new(f))); + Some(()) + } + 0x0142 => { + // A pattern the reader cannot decode still takes its handle slot. + let b = dib_pattern_brush(r).map_or(Obj::Other, Obj::Brush); + cv.add_object(None, b); + Some(()) + } + // Palettes, old-style pattern brushes and regions take a handle slot too, so later + // SELECTOBJECT handles count them; selecting one draws nothing. + 0x00F7 | 0x01F9 | 0x06FF => { cv.add_object(None, Obj::Other); Some(()) } + 0x0209 => r.u32(6).map(|c| cv.set_text_color(c)), + 0x0201 => r.u32(6).map(|c| cv.set_bk_color(c)), + 0x0102 => r.u16(6).map(|m| cv.set_bk_mode(u32::from(m))), + 0x012E => r.u16(6).map(|a| cv.set_text_align(u32::from(a))), + 0x0A32 => ext_text_out(cv, r), + 0x0521 => text_out(cv, r), + // PATBLT: rop(4), height, width, y, x. + 0x061D => r.u32(6).map(|rop| cv.fill_rop([i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)], rop)), 0x012D => r.u16(6).map(|h| cv.select(wmf_handle(h))), 0x01F0 => r.u16(6).map(|h| cv.delete_object(u32::from(h))), // STRETCHDIB: rop(4) usage(2) srcH srcW ySrc xSrc destH destW yDest xDest, then the DIB. @@ -168,11 +193,93 @@ fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> { Some(()) } -/// CREATEBRUSHINDIRECT: style, colour, hatch (hatch is not drawn). +/// CREATEBRUSHINDIRECT: style, colour, hatch. fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> { - let style = r.u16(6)?; + let style = u32::from(r.u16(6)?); let color = r.u32(8)?; - cv.add_object(None, Obj::Brush(Brush { style: u32::from(style), color: colorref(color) })); + let mut b = Brush::new(style, colorref(color)); + if style == BS_HATCHED { + b.hatch = u32::from(r.u16(12).unwrap_or(0)); + } + cv.add_object(None, Obj::Brush(b)); + Some(()) +} + +/// DIBCREATEPATTERNBRUSH: style, colour usage, then a DIB (MS-WMF 2.3.4.8). The DIB's own palette +/// gives the colours; with DIB_PAL_COLORS (indices into a palette the reader does not keep) a +/// monochrome DIB takes the text and background colours. +fn dib_pattern_brush(r: Bytes) -> Option { + let usage = r.u16(8)?; + let dib = r.tail(10)?; + let n = dib::info_len(dib)?; + let img = dib::decode(Bytes(dib.slice(0, n)?), dib.tail(n)?, false)?; + Brush::pattern(img, usage == 1) +} + +/// Font object (MS-WMF 2.2.1.2): height, width, escapement, orientation, weight (i16 each), italic, +/// underline, strike-out and charset bytes, three precision/quality bytes, pitch and family, then the +/// face name (up to 32 bytes, NUL-terminated, in the font's character set). +fn font(r: Bytes) -> Option { + let height = r.i16(6)?; + let width = r.i16(8).unwrap_or(0); + let escapement = r.i16(10).unwrap_or(0); + let weight = r.i16(14).unwrap_or(0); + let byte = |o: usize| r.0.get(o).copied().unwrap_or(0); + let charset = byte(19); + let name = r.0.get(24..).unwrap_or(&[]); + let name = name.get(..32).unwrap_or(name); + let name = name.split(|b| *b == 0).next().unwrap_or(&[]); + // Face names are plain ASCII in practice; other bytes go through the Western code page. + let (family, _) = decode_bytes(name, 0, &[]); + Some(LogFont { + font: Font { + family: family.trim().to_string(), + height: f32::from(height), + width: f32::from(width), + weight: weight.clamp(0, 1000) as u16, + italic: byte(16) != 0, + underline: byte(17) != 0, + strike: byte(18) != 0, + charset, + }, + escapement: f64::from(escapement), + }) +} + +/// The charset of the selected font, for decoding 8-bit text. +fn charset(cv: &Canvas) -> u8 { + cv.font_charset() +} + +/// EXTTEXTOUT (MS-WMF 2.3.3.5): y, x, string length, options, the rectangle (left, top, right, +/// bottom) when ETO_OPAQUE or ETO_CLIPPED is set, the string padded to a word, then an optional dx +/// array of i16 advances. +fn ext_text_out(cv: &mut Canvas, r: Bytes) -> Option<()> { + let (y, x) = (r.i16(6)?, r.i16(8)?); + let n = usize::from(r.u16(10)?).min(MAX_CHARS); + let opts = u32::from(r.u16(12)?); + let has_rect = opts & (ETO_OPAQUE | ETO_CLIPPED) != 0; + let rect = if has_rect { Some([i16s(r, 14), i16s(r, 16), i16s(r, 18), i16s(r, 20)]) } else { None }; + let at = if has_rect { 22 } else { 14 }; + // A string running past the record is cut to what the record holds. + let avail = r.0.len().saturating_sub(at); + let n = n.min(avail); + let bytes = r.slice(at, n)?; + let dx_at = at + n.next_multiple_of(2); + let dx: Vec = if r.0.len() >= dx_at + 2 * n { (0..n).filter_map(|i| r.i16(dx_at + 2 * i)).map(f32::from).collect() } else { Vec::new() }; + let (text, dx) = decode_bytes(bytes, charset(cv), &dx); + cv.text((f64::from(x), f64::from(y)), text, dx, opts, rect); + Some(()) +} + +/// TEXTOUT (MS-WMF 2.3.3.20): string length, the string padded to a word, then y and x. +fn text_out(cv: &mut Canvas, r: Bytes) -> Option<()> { + let n = usize::from(r.u16(6)?).min(MAX_CHARS); + let bytes = r.slice(8, n)?; + let at = 8 + n.next_multiple_of(2); + let (y, x) = (r.i16(at)?, r.i16(at + 2)?); + let (text, _) = decode_bytes(bytes, charset(cv), &[]); + cv.text((f64::from(x), f64::from(y)), text, Vec::new(), 0, None); Some(()) } diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 692cfe56..057614eb 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -711,6 +711,10 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout if let Some(tree) = csld.child("spTree") { s.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0); } + if let Some(c) = csld.child("controls") { + let pics = shapes::controls(imp, part, &mut ctx, c); + s.shapes.extend(pics); + } } if let Some(t) = root.child("transition") { s.transition = timing::transition(imp, part, t); diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index db778de0..51b2b863 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -87,6 +87,22 @@ pub fn sp_tree(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, tree: &El, depth: us out } +/// ActiveX controls (`p:controls`): each control's picture (`p:pic`), the image PowerPoint shows +/// for it, as a picture above the slide's shapes. Controls without one are left out. +pub fn controls(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, controls: &El) -> Vec { + let mut out = vec![]; + for c in controls.children_named("control") { + if ctx.count >= MAX_SHAPES { + break; + } + if let Some(p) = c.child("pic") { + ctx.count += 1; + out.push(pic(imp, part, ctx, p)); + } + } + out +} + fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) { if depth > MAX_TREE_DEPTH { return; diff --git a/crates/pptx/tests/ole_vml.rs b/crates/pptx/tests/ole_vml.rs new file mode 100644 index 00000000..8f6c007b --- /dev/null +++ b/crates/pptx/tests/ole_vml.rs @@ -0,0 +1,84 @@ +//! Legacy OLE objects whose preview picture lives in the slide's VML drawing, and ActiveX controls +//! shown by their pictures. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_model::ShapeKind; +use deckcraft_model::defaults; + +const FRAME: &str = r#""#; + +const VML: &str = r#""#; + +const VML_RELS: &str = r#""#; + +const SLIDE_RELS: &str = r#""#; + +/// An ActiveX control as PowerPoint writes it: a VML-only choice, and a fallback with its picture. +const CONTROLS: &str = r#""#; + +fn png(v: u8) -> Vec { + let mut out = Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(2, 2, image::Rgb([v, v, v]))) + .write_to(&mut out, image::ImageFormat::Png) + .expect("png"); + out.into_inner() +} + +/// A new deck whose first slide holds [`FRAME`], with the VML drawing, its rels and two pictures. +fn deck() -> Vec { + let bytes = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export"); + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + let name = f.name().to_string(); + if name == "ppt/slides/slide1.xml" { + let s = String::from_utf8(d).expect("utf8"); + let at = s.rfind("").expect("spTree"); + let s = format!("{}{FRAME}{}", &s[..at], &s[at..]); + let at = s.rfind("").expect("cSld"); + d = format!("{}{CONTROLS}{}", &s[..at], &s[at..]).into_bytes(); + } else if name == "ppt/slides/_rels/slide1.xml.rels" { + let s = String::from_utf8(d).expect("utf8"); + d = s.replace("", &format!("{SLIDE_RELS}")).into_bytes(); + } + out.start_file(name, o).expect("start"); + out.write_all(&d).expect("write"); + } + for (name, data) in [ + ("ppt/drawings/vmlDrawing1.vml", VML.as_bytes().to_vec()), + ("ppt/drawings/_rels/vmlDrawing1.vml.rels", VML_RELS.as_bytes().to_vec()), + ("ppt/media/other.png", png(10)), + ("ppt/media/preview.png", png(200)), + ] { + out.start_file(name, o).expect("start"); + out.write_all(&data).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +#[test] +fn an_ole_object_without_a_picture_shows_its_vml_preview() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("frame"); + let ShapeKind::Opaque { preview: Some(m), label, .. } = &s.kind else { panic!("{:?}", s.kind) }; + assert_eq!(label, "Thing.1"); + let media = p.media.iter().find(|x| x.id == *m).expect("media"); + // The shape whose id is the object's spid, not the first one in the drawing. + assert_eq!(media.name, "preview.png"); +} + +#[test] +fn activex_controls_show_their_pictures() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + let s = p.slides[0].shapes.iter().find(|s| s.name == "CheckBox1").expect("control picture"); + let ShapeKind::Picture { fill } = &s.kind else { panic!("{:?}", s.kind) }; + assert_eq!(p.media.iter().find(|m| m.id == fill.media).map(|m| m.name.as_str()), Some("preview.png")); + let x = s.xfrm.expect("xfrm"); + assert_eq!((x.x, x.y, x.w, x.h), (1.0, 2.0, 10.0, 5.0)); + assert!(!p.slides[0].shapes.iter().any(|s| s.name == "NoPicture"), "a control without a picture draws nothing"); +} diff --git a/crates/render/src/metafile.rs b/crates/render/src/metafile.rs index 8afcfb25..12350971 100644 --- a/crates/render/src/metafile.rs +++ b/crates/render/src/metafile.rs @@ -1,17 +1,30 @@ //! Windows Metafile and Enhanced Metafile pictures (`image/x-wmf`, `image/x-emf`): parsed by //! `deckcraft-metafile` and drawn as vectors into a bitmap of the size the slide needs, so they go //! through the same crop, tile and recolour path as any other picture. +//! +//! Text items name the font the file asks for; they are drawn with the face slide text would use +//! for that family (`FontDb::document_face`, so a missing font falls back the same way), symbol +//! fonts mapped as bullets map them, and the file's own advances (the dx array) placing each +//! character. +use std::collections::HashMap; use std::sync::Arc; -use deckcraft_metafile::PlacedItem; -use kurbo::{Affine, Rect, Shape as _}; +use deckcraft_fonts::{FontDb, FontFace}; +use deckcraft_metafile::{HAlign, Item, PlacedItem, Text, Tile, VAlign}; +use kurbo::{Affine, BezPath, Rect, Shape as _}; +use skrifa::MetadataProvider as _; use vello_cpu::{Pixmap, RenderContext, Resources, peniko}; /// Longest side of a metafile drawn without a target size (export, thumbnails of the picture). pub const DEFAULT_SIDE: u32 = 1024; /// Largest side drawn. const MAX_SIDE: u32 = 8192; +/// Pattern cells smaller than this (output pixels) are drawn as the tile's average colour: a +/// sub-pixel pattern would only alias. +const MIN_CELL_PX: f64 = 1.5; +/// Size of text whose font gives no height (LOGFONT height 0), in text units. +const DEFAULT_EM: f64 = 12.0; /// Is `bytes` a WMF or EMF file? pub fn is_metafile(bytes: &[u8]) -> bool { @@ -31,6 +44,8 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option { let w = (pw * k).round().clamp(1.0, MAX_SIDE as f64) as u16; let h = (ph * k).round().clamp(1.0, MAX_SIDE as f64) as u16; let pixmaps: Vec>> = pic.items.iter_mut().map(|it| it.take_pixels().and_then(|(w, h, px)| premul(w, h, &px))).collect(); + // Pattern tiles shared by several fills are converted once. + let mut tiles: HashMap<*const Tile, (Option>, peniko::Color)> = HashMap::new(); let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); let r = Rect::new(0.0, 0.0, w as f64, h as f64); ctx.push_clip_layer(&r.to_path(0.1)); @@ -60,6 +75,43 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option { ctx.fill_rect(&rect); ctx.reset_paint_transform(); } + PlacedItem::Pattern { index, path, even_odd, tile } => { + let Some(Item::Pattern { tile: t, .. }) = pic.items.get(index) else { continue }; + let key = Arc::as_ptr(t); + let (pm, avg) = tiles.entry(key).or_insert_with(|| (premul(t.width, t.height, &t.rgba), average(&t.rgba))).clone(); + ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); + let cell = tile.determinant().abs().sqrt(); + match pm { + Some(pm) if cell >= MIN_CELL_PX => { + ctx.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm), + sampler: peniko::ImageSampler { + x_extend: peniko::Extend::Repeat, + y_extend: peniko::Extend::Repeat, + quality: peniko::ImageQuality::Low, + alpha: 1.0, + }, + }); + ctx.set_paint_transform(tile); + ctx.fill_path(&path); + ctx.reset_paint_transform(); + } + _ => { + ctx.set_paint(avg); + ctx.fill_path(&path); + } + } + } + PlacedItem::Text { index, transform, clip } => { + let Some(Item::Text(t)) = pic.items.get(index) else { continue }; + if let Some(c) = clip { + ctx.push_clip_layer(&c.to_path(0.1)); + } + draw_text(&mut ctx, t, transform); + if clip.is_some() { + ctx.pop_layer(); + } + } } } ctx.set_fill_rule(peniko::Fill::NonZero); @@ -81,10 +133,213 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option { image::RgbaImage::from_raw(w as u32, h as u32, px) } +/// One glyph placed in text space (x along the baseline from the string's start, font units at +/// `scale` text units each). +struct Glyph { + face: Arc, + gid: u32, + x: f64, + y: f64, +} + +/// Draws a text item; `m` maps its text space to the output. +fn draw_text(ctx: &mut RenderContext, t: &Text, m: Affine) { + let db = FontDb::global(); + let font = &t.font; + let family = if font.family.trim().is_empty() { "Arial" } else { font.family.trim() }; + let (bold, italic) = (font.weight >= 600, font.italic); + let style = match (bold, italic) { + (true, true) => "Bold Italic", + (true, false) => "Bold", + (false, true) => "Italic", + (false, false) => "Regular", + }; + let face = db.document_face(family, style); + let upem = face.upem.max(1.0); + let (asc, desc) = win_metrics(&face); + // LOGFONT height: negative is the em, positive the cell (ascent plus descent). + let h = f64::from(font.height); + let em = if h < 0.0 { + -h + } else if h > 0.0 { + h * upem / (asc + desc).max(1.0) + } else { + DEFAULT_EM + }; + let k = em / upem; + if !k.is_finite() || k <= 0.0 { + return; + } + // Characters as drawn: symbol fonts (Wingdings…) map like bullets; each character takes the + // face that has it. + let chars: Vec = t.text.chars().map(|c| deckcraft_text::symbol_char(&face, family, c).unwrap_or(c)).collect(); + let faces: Vec> = chars + .iter() + .map(|c| { + if c.is_whitespace() || c.is_control() || face.covers(*c) { face.clone() } else { db.cascade(&[*c], std::slice::from_ref(&face), style) } + }) + .collect(); + // Shape runs of one face; record each glyph's pen position and its character. + let mut shaped: Vec<(Glyph, usize)> = Vec::with_capacity(chars.len()); + let mut char_pen: Vec = vec![0.0; chars.len() + 1]; + let mut pen = 0.0; + let mut i = 0; + while i < chars.len() { + let Some(f) = faces.get(i).cloned() else { break }; + let mut j = i + 1; + while faces.get(j).is_some_and(|g| g.id() == f.id()) { + j += 1; + } + let run: String = chars.get(i..j).unwrap_or(&[]).iter().collect(); + // Byte offset in the run → character index in the string. + let byte_char: Vec = run.char_indices().map(|(b, _)| b).collect(); + let ku = f.upem.max(1.0); + for g in deckcraft_fonts::shape(&f, &run, &[], |c| c) { + let ci = i + byte_char.partition_point(|b| *b < g.cluster).min(j - i - 1); + let s = em / ku; + shaped.push((Glyph { face: f.clone(), gid: g.gid, x: pen + f64::from(g.x_offset) * s, y: -f64::from(g.y_offset) * s }, ci)); + pen += f64::from(g.x_advance) * s; + if let Some(p) = char_pen.get_mut(ci + 1) { + *p = pen; + } + } + i = j; + } + // Character start pens: a character's start is the previous one's end. + for ci in 1..char_pen.len() { + let prev = char_pen.get(ci - 1).copied().unwrap_or(0.0); + if let Some(p) = char_pen.get_mut(ci) + && *p < prev + { + *p = prev; + } + } + // The file's advances place each character; glyphs keep their offset within their character. + let use_dx = !t.dx.is_empty() && t.dx.len() == chars.len(); + let mut width = pen; + let mut x_scale = 1.0; + if use_dx { + let mut starts = Vec::with_capacity(chars.len() + 1); + let mut x = 0.0; + for d in &t.dx { + starts.push(x); + x += f64::from(*d); + } + width = x; + for (g, ci) in &mut shaped { + let (start, pen_start) = (starts.get(*ci).copied().unwrap_or(0.0), char_pen.get(*ci).copied().unwrap_or(0.0)); + g.x = start + (g.x - pen_start); + } + } else if font.width > 0.0 { + // A LOGFONT width narrows or widens the font against its average character width. + let avg = avg_char_width(&face) * k; + if avg > 0.0 { + x_scale = (f64::from(font.width) / avg).clamp(0.05, 20.0); + width *= x_scale; + for (g, _) in &mut shaped { + g.x *= x_scale; + } + } + } + let x0 = match t.h_align { + HAlign::Left => 0.0, + HAlign::Center => -width / 2.0, + HAlign::Right => -width, + }; + let (a, d) = (asc * k, desc * k); + let base = match t.v_align { + VAlign::Baseline => 0.0, + VAlign::Top => a, + VAlign::Bottom => -d, + }; + let at = m * Affine::translate((x0, base)); + ctx.set_fill_rule(peniko::Fill::NonZero); + if let Some(bg) = t.background + && width > 0.0 + { + ctx.set_paint(rgba(bg)); + ctx.fill_path(&(at * Rect::new(0.0, -a, width, d).to_path(0.1))); + } + let color = rgba(t.color); + ctx.set_paint(color); + for (g, _) in &shaped { + let path = db.outline(&g.face, g.gid); + if path.elements().is_empty() { + continue; + } + let s = em / g.face.upem.max(1.0); + let skew = if italic && !g.face.italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let gm = at * Affine::translate((g.x, g.y)) * Affine::scale_non_uniform(x_scale, 1.0) * skew * Affine::scale(s); + let p: BezPath = gm * path.as_ref().clone(); + ctx.fill_path(&p); + if bold && g.face.weight < 600.0 { + ctx.set_stroke(kurbo::Stroke::new(em * 0.025 * scale_of(at)).with_join(kurbo::Join::Round)); + ctx.stroke_path(&p); + } + } + // Underline and strike-out: the font's own positions and thickness. + let metrics = face.skrifa().map(|f| f.metrics(skrifa::instance::Size::unscaled(), face.location())); + let deco = |on: bool, d: Option, fallback: (f64, f64)| -> Option { + if !on || width <= 0.0 { + return None; + } + let (off, th) = d.map_or(fallback, |d| (f64::from(d.offset) * k, f64::from(d.thickness) * k)); + let th = if th > 0.0 { th } else { em / 20.0 }; + // Offsets are y-up from the baseline; text space is y-down. + Some(Rect::new(0.0, -off - th / 2.0, width, -off + th / 2.0)) + }; + let lines = [ + deco(font.underline, metrics.as_ref().and_then(|m| m.underline), (-em * 0.1, em / 20.0)), + deco(font.strike, metrics.as_ref().and_then(|m| m.strikeout), (em * 0.26, em / 20.0)), + ]; + for r in lines.into_iter().flatten() { + ctx.fill_path(&(at * r.to_path(0.1))); + } +} + +/// Mean scale of an affine transform's linear part. +fn scale_of(m: Affine) -> f64 { + m.determinant().abs().sqrt() +} + +/// The face's Windows ascent and descent (OS/2 `usWinAscent`/`usWinDescent`, as GDI measures the +/// cell), in font units; the typographic ones when the font has no OS/2 table. +fn win_metrics(face: &FontFace) -> (f64, f64) { + use skrifa::raw::TableProvider as _; + let os2 = face.skrifa().and_then(|f| f.os2().ok()).map(|o| (f64::from(o.us_win_ascent()), f64::from(o.us_win_descent()))); + match os2 { + Some((a, d)) if a + d > 0.0 => (a, d), + _ => (face.ascent.max(0.0), face.descent.max(0.0)), + } +} + +/// OS/2 `xAvgCharWidth` in font units (half an em without it). +fn avg_char_width(face: &FontFace) -> f64 { + use skrifa::raw::TableProvider as _; + face.skrifa().and_then(|f| f.os2().ok()).map(|o| f64::from(o.x_avg_char_width())).filter(|w| *w > 0.0).unwrap_or(face.upem * 0.5) +} + fn rgba(c: [u8; 4]) -> peniko::Color { peniko::Color::from_rgba8(c[0], c[1], c[2], c[3]) } +/// The average colour of straight RGBA pixels (alpha-weighted). +fn average(px: &[u8]) -> peniko::Color { + let (mut r, mut g, mut b, mut a, mut n) = (0u64, 0u64, 0u64, 0u64, 0u64); + for p in px.as_chunks::<4>().0 { + let w = u64::from(p[3]); + r += u64::from(p[0]) * w; + g += u64::from(p[1]) * w; + b += u64::from(p[2]) * w; + a += w; + n += 1; + } + if a == 0 || n == 0 { + return peniko::Color::from_rgba8(0, 0, 0, 0); + } + peniko::Color::from_rgba8((r / a) as u8, (g / a) as u8, (b / a) as u8, (a / n) as u8) +} + /// Straight RGBA rows to a premultiplied pixmap. fn premul(w: u32, h: u32, px: &[u8]) -> Option> { if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 || px.len() < w as usize * h as usize * 4 { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index d9a5883e..ee713911 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1268,3 +1268,178 @@ fn text_spilling_out_of_its_box_below_the_slide_is_reported() { let (_, drawn) = render_slide_drawn(&Presentation::default(), 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() }); assert_eq!(drawn, Rect::new(0.0, 0.0, 960.0, 540.0)); } + +/// A placeable WMF (1440 units per inch, bounds `w` × `h`) holding `records` (function, parameter +/// bytes). +fn wmf_with(w: i16, h: i16, records: &[(u16, Vec)]) -> Vec { + let mut body = Vec::new(); + for (func, params) in records { + let mut p = params.clone(); + if p.len() % 2 == 1 { + p.push(0); + } + body.extend(((6 + p.len()) as u32 / 2).to_le_bytes()); + body.extend(func.to_le_bytes()); + body.extend(p); + } + body.extend([3, 0, 0, 0, 0, 0]); + let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec(); + f.extend(0u16.to_le_bytes()); + for v in [0i16, 0, w, h] { + f.extend(v.to_le_bytes()); + } + f.extend(1440u16.to_le_bytes()); + f.extend(0u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + let total = (f.len() + 18 + body.len()) / 2; + for v in [1u16, 9, 0x300] { + f.extend(v.to_le_bytes()); + } + f.extend((total as u32).to_le_bytes()); + f.extend(16u16.to_le_bytes()); + f.extend(0u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + f.extend(body); + f +} + +fn i16s(v: &[i32]) -> Vec { + v.iter().flat_map(|x| (*x as i16).to_le_bytes()).collect() +} + +/// CREATEFONTINDIRECT (height, escapement, face) then SELECTOBJECT of it (handle 0), transparent +/// background, and the alignment `ta`. +fn font_records(height: i32, esc: i32, face: &str, ta: i32) -> Vec<(u16, Vec)> { + let mut f = i16s(&[height, 0, esc, esc, 400]); + f.extend([0, 0, 0, if face.contains("dings") { 2 } else { 0 }, 0, 0, 0, 0]); + f.extend(face.as_bytes()); + f.push(0); + vec![(0x02FB, f), (0x012D, i16s(&[0])), (0x0102, i16s(&[1])), (0x012E, i16s(&[ta]))] +} + +/// EXTTEXTOUT at (x, y) with an optional dx array. +fn text_record(x: i32, y: i32, s: &str, dx: &[i32]) -> (u16, Vec) { + let mut v = i16s(&[y, x, s.len() as i32, 0]); + v.extend(s.as_bytes()); + if s.len() % 2 == 1 { + v.push(0); + } + v.extend(i16s(dx)); + (0x0A32, v) +} + +/// Bounding box (x0, y0, x1, y1) of the pixels at least half opaque and darker than mid-grey. +fn mf_ink(img: &image::RgbaImage) -> Option<(u32, u32, u32, u32)> { + let mut b: Option<(u32, u32, u32, u32)> = None; + for (x, y, p) in img.enumerate_pixels() { + if p[3] > 128 && (p[0] as u32 + p[1] as u32 + p[2] as u32) < 384 { + b = Some(match b { + None => (x, y, x, y), + Some((a, c, d, e)) => (a.min(x), c.min(y), d.max(x), e.max(y)), + }); + } + } + b +} + +#[test] +fn metafile_text_ink_lands_at_its_reference_point() { + // 2 × 1 inch (144 × 72 pt) drawn 288 px wide: 2 px per point. A 36-point "HH", top-left + // aligned at (72, 18) pt. + let mut recs = font_records(-720, 0, "Arial", 0); + recs.push(text_record(1440, 360, "HH", &[])); + let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster"); + let (x0, y0, x1, y1) = mf_ink(&img).expect("text ink"); + assert!((144..156).contains(&x0), "left edge at the reference point: {x0}"); + assert!(x1 > x0 + 40 && x1 < 288, "two capitals wide: {x1}"); + // The cell's top is at 36 px; a capital starts below the cell top and sits on the baseline. + assert!((38..72).contains(&y0), "cap top below the cell top: {y0}"); + assert!((88..112).contains(&y1), "baseline one ascent below the top: {y1}"); +} + +#[test] +fn metafile_text_centre_baseline_and_escapement() { + // TA_CENTER | TA_BASELINE at (72, 36) pt: the ink is centred on 144 px and sits on 72 px. + let mut recs = font_records(-720, 0, "Arial", 24 | 6); + recs.push(text_record(1440, 720, "HH", &[])); + let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster"); + let (x0, _, x1, y1) = mf_ink(&img).expect("ink"); + assert!(((x0 + x1) as i32 / 2 - 144).abs() <= 4, "centred: {x0}..{x1}"); + assert!((69..=74).contains(&y1), "on the baseline: {y1}"); + // Escapement 90°: the string runs up the picture. + let mut recs = font_records(-360, 900, "Arial", 24); + recs.push(text_record(1440, 1300, "HHHH", &[])); + let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster"); + let (x0, y0, x1, y1) = mf_ink(&img).expect("ink"); + assert!(y1 - y0 > 2 * (x1 - x0), "a vertical run: {x0} {y0} {x1} {y1}"); + assert!(x1 <= 146 && y1 <= 132, "drawn up and left of the baseline origin (144, 130): {x1} {y1}"); +} + +#[test] +fn metafile_text_follows_its_dx_array() { + // "HH" with the second H a full inch (144 px) after the first: two clumps of ink, a gap between. + let mut recs = font_records(-360, 0, "Arial", 24); + recs.push(text_record(360, 720, "HH", &[1440, 100])); + let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster"); + let col_has_ink = |x: u32| (0..img.height()).any(|y| img.get_pixel(x, y)[3] > 128); + assert!((36..56).any(col_has_ink), "first H at 36 px"); + assert!((180..200).any(col_has_ink), "second H one inch later"); + assert!(!(70..170).any(col_has_ink), "nothing between them"); +} + +#[test] +fn metafile_symbol_text_draws_a_bullet_not_a_box() { + // Webdings "n" is a filled circle (installed, or mapped to a black circle where it is not), as + // is Wingdings "l" in a symbol font that is not installed anywhere. + for (face, s) in [("Webdings", "n"), ("Wingdings Not Installed", "l")] { + let mut recs = font_records(-1080, 0, face, 0); + recs.push(text_record(360, 0, s, &[])); + let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster"); + let (x0, y0, x1, y1) = mf_ink(&img).expect("ink"); + assert!(x1 - x0 > 10 && y1 - y0 > 10, "{face}: a sizeable mark: {x0} {y0} {x1} {y1}"); + let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2); + assert!(img.get_pixel(cx, cy)[3] > 200, "{face}: filled in the middle"); + for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { + assert!(img.get_pixel(x, y)[3] < 128, "{face}: round, not a box: corner ({x}, {y})"); + } + } +} + +#[test] +fn metafile_opaque_text_fills_its_cell() { + // OPAQUE background in red behind a space. + let mut recs = font_records(-720, 0, "Arial", 0); + recs.push((0x0102, i16s(&[2]))); + recs.push((0x0201, 0x0000_00FFu32.to_le_bytes().to_vec())); + recs.push(text_record(1440, 360, " ", &[])); + let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster"); + let p = img.get_pixel(148, 60); + assert!(p[0] > 200 && p[1] < 60 && p[3] > 200, "cell filled red: {p:?}"); + assert!(img.get_pixel(140, 60)[3] < 50, "nothing left of the cell"); +} + +#[test] +fn metafile_pattern_brush_tiles_and_averages_when_tiny() { + // A 1-bit 8 × 8 checker (palette white, black), a null pen and a one-inch square. + let mut dib: Vec = [40u32, 8, 8].iter().flat_map(|v| v.to_le_bytes()).collect(); + dib.extend(1u16.to_le_bytes()); + dib.extend(1u16.to_le_bytes()); + dib.extend([0u8; 24]); + dib.extend([255, 255, 255, 0, 0, 0, 0, 0]); + for r in 0..8 { + dib.extend([if r % 2 == 0 { 0x55 } else { 0xAA }, 0, 0, 0]); + } + let mut brush = i16s(&[5, 0]); + brush.extend(dib); + let recs = vec![(0x0142, brush), (0x012D, i16s(&[0])), (0x012D, i16s(&[0x8008])), (0x041B, i16s(&[1440, 1440, 0, 0]))]; + let bytes = wmf_with(1440, 1440, &recs); + // 4 px per point: a 0.75 pt cell is 3 px. + let img = crate::metafile::rasterize(&bytes, 288).expect("raster"); + let (a, b) = (img.get_pixel(1, 1), img.get_pixel(4, 1)); + assert!(a[3] > 200 && b[3] > 200); + assert!((a[0] as i32 - b[0] as i32).abs() > 200, "neighbouring cells alternate: {a:?} {b:?}"); + // Far below one pixel per cell: the average grey. + let img = crate::metafile::rasterize(&bytes, 32).expect("raster"); + let p = img.get_pixel(16, 16); + assert!((100..156).contains(&p[0]) && p[3] > 200, "average grey: {p:?}"); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index ba58f920..cc09349a 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -773,9 +773,37 @@ fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option Option { + let code = c as u32; + if !is_symbol_font(font) || !(code < 0x100 || (0xF000..=0xF0FF).contains(&code)) { + return None; + } + let plain = char::from_u32(code & 0xFF)?; + let s = plain.to_string(); + Some(symbol_font_char(face, &s, Some(font)).unwrap_or_else(|| map_symbol_bullet(&s, Some(font)).chars().next().unwrap_or('•'))) +} + fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { let f = font.unwrap_or("").to_ascii_lowercase(); let first = c.chars().next().unwrap_or('•'); + if f.starts_with("webdings") { + let m = match (first as u32) & 0xff { + 0x6e => '●', + 0x67 => '■', + 0x61 => '✓', + 0x72 => '✕', + 0x33 => '◀', + 0x34 => '▶', + 0x35 => '▲', + 0x36 => '▼', + _ => '•', + }; + return m.to_string(); + } if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) { let code = (first as u32) & 0xff; // Wingdings 2 and 3 put different shapes at the same codes. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f3dd0132..930789b9 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -748,3 +748,14 @@ fn an_installed_symbol_font_draws_its_own_bullet() { assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Wingdings 2")), Some('\u{f0ea}')); assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Arial")), None); } + +#[test] +fn symbol_char_maps_symbol_font_codes() { + // Inter is not a symbol font, so the codes map to look-alikes. + let face = FontDb::global().face("Inter", "Regular"); + assert_eq!(symbol_char(&face, "Arial", 'n'), None, "not a symbol font"); + assert_eq!(symbol_char(&face, "Webdings", 'n'), Some('\u{25CF}')); + assert_eq!(symbol_char(&face, "Webdings", '\u{F06E}'), Some('\u{25CF}'), "the private-use form of the code"); + assert_eq!(symbol_char(&face, "Wingdings", '\u{F06E}'), Some('\u{25A0}')); + assert_eq!(symbol_char(&face, "Wingdings", '\u{100}'), None, "not a symbol code"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 519a118c..44685ea9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -38,7 +38,7 @@ PowerPoint opens and saves; Viewer = opens only). |---|---|---|---| | PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 | | SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted | -| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, bitmaps); text records inside are skipped | +| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances); clip regions and EMF+ records are not drawn. Legacy OLE objects (VML preview) and ActiveX controls show their pictures | | HEIC | yes (macOS) | no | | | Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` | | WMA | yes (Windows) | embedded, not playable | | @@ -93,11 +93,12 @@ the round trip even though DeckCraft doesn't model it. embedded fonts, every transition and animation) in a corpus repo, with import → render → export → re-import checks in CI and a PowerPoint reference render for each (owner runs PowerPoint). 2. The round-trip losses above (#70, #74, #77). -3. SVG rendering, and text inside EMF/WMF. +3. SVG rendering, and clip regions inside EMF/WMF. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read | | 2026-10-12 | minor | Text warps rendered; their adjust values read and written | | 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues | diff --git a/docs/gaps.md b/docs/gaps.md index a433df73..1d352232 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -20,7 +20,7 @@ revision history. | 1 | PPTX never tested against a corpus of real decks | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) | | 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file | | 3 | PPTX round trip drops comment threads, media options, rewind (#70, #74, #77) | File format | B | 3–6 | [file-format-parity.md](file-format-parity.md) | -| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 8–14 | [file-format-parity.md](file-format-parity.md) | +| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 6–12 | [file-format-parity.md](file-format-parity.md) | | 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) | | 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) | | 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) | @@ -77,12 +77,12 @@ Detail in [file-format-parity.md](file-format-parity.md). ### SVG pictures don't render - **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF, - TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens - and bitmaps; text records are skipped). Nothing reads SVG; PowerPoint's Insert Icons stores SVG - with a PNG fallback. -- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. Text - drawn inside an EMF/WMF is missing. -- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates); EMF text records 2–4 h. + TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens, + pattern and hatch brushes, bitmaps and text; clip regions and EMF+ records are not drawn). + Nothing reads SVG; PowerPoint's Insert Icons stores SVG with a PNG fallback. +- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. An + EMF/WMF that clips its drawing (EXTSELECTCLIPRGN, INTERSECTCLIPRECT) draws past its clip. +- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates); EMF/WMF clip regions 2–4 h. ### Template, slide-show and macro variants are saved as plain `.pptx` - **Evidence:** `crates/pptx/src/write/mod.rs` always writes the @@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | +| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | | 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles | | 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) | | 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked | From dc8a88438994ab30381ee034eafb17084c7747fd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:09:54 +0200 Subject: [PATCH 41/49] Render: a group's child-coordinate scale no longer scales line widths (#233) Outlines, dashes, arrowheads and ink strokes were drawn under the full transform, including the mapping of the group's child coordinate space onto its box. A group whose children use small legacy child coordinates (scale ~1600) therefore stroked 0.75 pt lines about 1200 pt wide and covered the slide. Inside a group the outline is now carried through that mapping and stroked under the slide-level view, so the line width, dash lengths and arrowhead sizes stay in points, also for groups scaled unevenly. --- crates/render/src/lib.rs | 61 +++++++++++++++++++++++++++++++----- crates/render/src/tests.rs | 63 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8f88024d..637f0aea 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -238,11 +238,14 @@ pub struct Renderer { /// What the frame being drawn has painted so far, in output pixels: the editor learns from it /// how far a slide's drawing (text spilling out of its box too) reaches off the slide. drawn: Option, + /// The transform a top-level shape is drawn under (slide to pixels); a group's children draw + /// under it combined with the group's child-coordinate mapping, which must not scale lines. + view: Affine, } impl Default for Renderer { fn default() -> Self { - Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None } + Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None, view: Affine::IDENTITY } } } @@ -660,6 +663,25 @@ impl Renderer { } fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { + if depth == 0 { + let outer = std::mem::replace(&mut self.view, parent); + self.shape_at(ctx, f, rctx, s, parent, depth); + self.view = outer; + } else { + self.shape_at(ctx, f, rctx, s, parent, depth); + } + } + + /// How lines are drawn for a shape under `m` at `depth`: the transform to stroke under and the + /// one that takes the shape's outline into it. Inside a group they are the slide-level view and + /// the rest of the chain, so the group's child-coordinate scale moves and sizes the outline but + /// never the line width; outside a group, `m` itself and no change to the outline. + fn line_space(&self, m: Affine, depth: usize) -> (Affine, Affine) { + let view = self.view; + if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) } + } + + fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) { return; } @@ -718,20 +740,34 @@ impl Renderer { bp.line_to((*px, *py)); } } - ctx.set_transform(parent); + let (base, rel) = self.line_space(parent, depth); + bp.apply_affine(rel); + ctx.set_transform(base); ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 })); ctx.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); ctx.stroke_path(&bp); } } - _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st), + _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth), } if layered { ctx.pop_layer(); } } - fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) { + #[allow(clippy::too_many_arguments)] + fn geometry_shape( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + rctx: &Ctx, + s: &Shape, + m: Affine, + w: f64, + h: f64, + st: &ShapeState, + depth: usize, + ) { let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) { Some(pm) => morph_path::geometry(pm, s, w, h), None => (shape_geometry(s, w, h), Vec::new()), @@ -816,11 +852,19 @@ impl Renderer { // Outline. if line.fill.as_ref().is_some_and(|f| !f.is_none()) { let lw = line.width.unwrap_or(0.75).max(0.0); + let (base, rel) = self.line_space(m, depth); for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) { - stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw, fade(i).stroke); + let path = if rel == Affine::IDENTITY { + std::borrow::Cow::Borrowed(&sp.path) + } else { + let mut moved = sp.path.clone(); + moved.apply_affine(rel); + std::borrow::Cow::Owned(moved) + }; + stroke(ctx, rctx, &line, line_ph, &path, base, lw, fade(i).stroke); } if s.is_line() || geo.is_open() { - arrowheads(ctx, rctx, &line, line_ph, &geo, m, lw); + arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw); } } if let Some(r) = soft { @@ -1069,9 +1113,12 @@ fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> { Some(((first, second), (last, prev))) } -fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, geo: &Geometry, m: Affine, lw: f64) { +#[allow(clippy::too_many_arguments)] +fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, geo: &Geometry, rel: Affine, m: Affine, lw: f64) { let Some(sp) = geo.paths.iter().find(|p| p.stroke) else { return }; let Some(((a, a2), (b, b2))) = end_points(&sp.path) else { return }; + // The outline's ends in the space the arrowheads are sized and drawn in. + let (a, a2, b, b2) = (rel * a, rel * a2, rel * b, rel * b2); let col = match &line.fill { Some(Fill::Solid { color: c }) => rctx.color(c, ph), _ => Rgba::BLACK, diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ee713911..f2a241b7 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1443,3 +1443,66 @@ fn metafile_pattern_brush_tiles_and_averages_when_tiny() { let p = img.get_pixel(16, 16); assert!((100..156).contains(&p[0]) && p[3] > 200, "average grey: {p:?}"); } + +/// A 400 x 200 group at (100, 100) holding a dashed 4 pt rectangle and a 3 pt line with an +/// arrowhead, placed in the group's child coordinates by `to_child` (slide point to child point). +fn outlined_group(child: Xfrm, to_child: impl Fn(f64, f64) -> (f64, f64)) -> Image { + use deckcraft_model::style::{Dash, LineEnd}; + let ink = |w: f64| deckcraft_model::style::Line { dash: None, ..deckcraft_model::style::Line::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)), w) }; + let place = |x0: f64, y0: f64, x1: f64, y1: f64| { + let ((a, b), (c, d)) = (to_child(x0, y0), to_child(x1, y1)); + Xfrm::new(a, b, c - a, d - b) + }; + let rect = Shape { + id: ShapeId(11), + xfrm: Some(place(140.0, 120.0, 300.0, 200.0)), + geom: Geom::preset("rect"), + line: Some(deckcraft_model::style::Line { dash: Some(Dash::Preset { name: "dash".into() }), ..ink(4.0) }), + ..Default::default() + }; + let end = LineEnd { kind: "triangle".into(), w: "lg".into(), len: "lg".into() }; + let line = Shape { + id: ShapeId(12), + xfrm: Some(Xfrm { flip_v: true, ..place(150.0, 170.0, 450.0, 260.0) }), + geom: Geom::preset("line"), + line: Some(deckcraft_model::style::Line { tail: Some(end), ..ink(3.0) }), + ..Default::default() + }; + let pts: Vec<(f64, f64, f32)> = [(160.0, 240.0), (260.0, 285.0)].iter().map(|(x, y)| to_child(*x, *y)).map(|(x, y)| (x, y, 1.0)).collect(); + let ink = Shape { + id: ShapeId(13), + xfrm: Some(place(100.0, 100.0, 500.0, 300.0)), + kind: ShapeKind::Ink { + strokes: vec![deckcraft_model::InkStroke { points: pts, color: Rgba::rgb(200, 0, 0), width: 5.0, highlighter: false }], + }, + ..Default::default() + }; + let group = Shape { + id: ShapeId(10), + xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), + kind: ShapeKind::Group { children: vec![rect, line, ink], child }, + ..Default::default() + }; + render_slide(&deck_with(vec![group]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +fn inked(img: &Image) -> usize { + img.pixels.as_chunks::<4>().0.iter().filter(|px| px[1] < 200).count() +} + +#[test] +fn group_child_scale_does_not_scale_line_widths() { + // Children laid out 1:1 with the slide are the reference. + let reference = outlined_group(Xfrm::new(100.0, 100.0, 400.0, 200.0), |x, y| (x, y)); + let drawn = inked(&reference); + assert!((500..20_000).contains(&drawn), "the reference draws thin lines: {drawn}"); + // The same drawing in a small legacy child space (scale 100), and one scaled unevenly + // (100 across, 50 down): same outlines, same line widths, dashes and arrowhead. + let uniform = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 2.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 100.0)); + let uneven = outlined_group(Xfrm::new(1.0, 1.0, 4.0, 4.0), |x, y| (1.0 + (x - 100.0) / 100.0, 1.0 + (y - 100.0) / 50.0)); + for (name, img) in [("uniform", &uniform), ("uneven", &uneven)] { + let (n, d) = (inked(img), differing(&reference, img)); + assert!(n.abs_diff(drawn) * 20 < drawn, "{name}: {n} inked pixels against {drawn}"); + assert!(d * 20 < drawn, "{name}: {d} differing values against {drawn} inked pixels"); + } +} From d4bc8812a3d62d151073188ab1cde611ce2a4919 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:09:54 +0200 Subject: [PATCH 42/49] Credit Silvertree2010 in the About window --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index b852df13..c10fb209 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -37,3 +37,7 @@ sync_github_name = false [people.Johannesgauss] sync_github_name = true + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false From c4f355773d9806b8e24acef1446b9623baec997d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:06:38 +0200 Subject: [PATCH 43/49] Docs: update the progress docs for this change --- ROADMAP.md | 2 ++ docs/file-format-parity.md | 5 +++-- docs/ui-parity.md | 2 -- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 813474d8..28c52d95 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection | | 2026-10-12 | EMF/WMF pictures draw their text (fonts, alignment, dx advances, escapement, symbol fonts), pattern and hatch brushes, PATBLT and the XOR/AND see-through shadow; legacy OLE objects show their VML preview and ActiveX controls their pictures | | 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | +| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | | 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG | @@ -145,6 +146,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials | | 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% | | 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 44685ea9..b1904811 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); previously major: created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -63,7 +63,7 @@ the round trip even though DeckCraft doesn't model it. | Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept | | Notes slides and notes master | yes | yes | No notes-master editing view | | Handout master | no | no | | -| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`) | +| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group keep their width in points whatever the group's child-coordinate scale (#233); text in a group with a tiny child space is not drawn yet. | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | | | Lines: dash, compound, caps, joins, arrowheads | yes | yes | | | Effects: shadow, glow, soft edges, reflection | yes | yes | | @@ -101,4 +101,5 @@ the round trip even though DeckCraft doesn't model it. |---|---|---| | 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read | | 2026-10-12 | minor | Text warps rendered; their adjust values read and written | +| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) | | 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8a57ed4d..065040ef 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -120,8 +120,6 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | - | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock | - | 2026-10-12 | minor | Text editing keys: Ctrl+arrows, Ctrl+Backspace/Delete, Ctrl+Up/Down, Home/End on wrapped lines, Shift+F3, font size keys, as observed in PowerPoint for Windows (#268) | | 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 | From bb6f0dc88aa94a87585c61f769e9d4f8314ced00 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 22:46:32 -0400 Subject: [PATCH 44/49] Render: pictures cast their shadow from their pixels, as in PowerPoint (#338) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 28 ++++++++++++++++++++--- crates/render/src/tests.rs | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 637f0aea..c20700ca 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -785,7 +785,12 @@ impl Renderer { let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill }; // Effects below the shape. if let Some(e) = &effects { - self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, m, w, h); + // A picture (or picture fill) casts its shadow from its pixels, not its frame. + let content = match (&s.kind, &fill) { + (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()), + _ => None, + }; + self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, content.as_ref(), m, w, h); } let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0); if let Some(r) = soft { @@ -959,6 +964,7 @@ impl Renderer { ph: Option, geo: &Geometry, fill: &Option, + content: Option<&deckcraft_model::style::PictureFill>, m: Affine, w: f64, h: f64, @@ -986,8 +992,24 @@ impl Renderer { // Blurred by a third of the blur radius, as PowerPoint's export shows. ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k))); ctx.set_transform(sm); - ctx.set_paint(color(c.with_alpha(255), 1.0)); - ctx.fill_path(&silhouette); + match content { + // As PowerPoint draws it: the shadow is the picture's own coverage (a logo on a + // transparent background casts the logo's shadow): the picture, clipped to the + // shape, recoloured to the shadow colour. + Some(pf) => { + ctx.push_clip_layer(&silhouette); + paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None); + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None); + ctx.set_paint(color(c.with_alpha(255), 1.0)); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0)); + ctx.pop_layer(); + ctx.pop_layer(); + } + None => { + ctx.set_paint(color(c.with_alpha(255), 1.0)); + ctx.fill_path(&silhouette); + } + } ctx.pop_layer(); } if let Some(g) = &e.glow { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index f2a241b7..5ccefb96 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1506,3 +1506,50 @@ fn group_child_scale_does_not_scale_line_widths() { assert!(d * 20 < drawn, "{name}: {d} differing values against {drawn} inked pixels"); } } + +#[test] +fn a_picture_casts_its_shadow_from_its_pixels() { + // PowerPoint shades only where a picture paints: a logo on a transparent background casts the + // logo's shadow, not its frame's. Here the left half of the image is transparent. + let png = { + let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([20, 160, 20, 255]) }); + let mut out = std::io::Cursor::new(vec![]); + image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() + }; + let shadow = deckcraft_model::style::Shadow { + color: ColorRef::rgb(deckcraft_color::Rgba::BLACK), + blur: 0.0, + dist: 20.0, + dir: 90.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + }; + for as_fill in [false, true] { + let mut p = Presentation::default(); + let media = p.add_media("logo.png", "image/png", png.clone()); + let pf = deckcraft_model::style::PictureFill { media, ..Default::default() }; + let mut sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), ..Default::default() }; + if as_fill { + sh.fill = Some(Fill::Picture(pf)); + sh.line = Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }); + } else { + sh.kind = ShapeKind::Picture { fill: pf }; + } + sh.effects = Some(deckcraft_model::Effects { outer_shadow: Some(shadow.clone()), ..Default::default() }); + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + // Below the picture, 10 pt down: shadow under the opaque right half only. + let left = img.pixel(200, 310); + let right = img.pixel(400, 310); + assert!(left[0] > 240, "no shadow under the transparent half ({as_fill}): {left:?}"); + assert!(right[0] < 40, "shadow under the opaque half ({as_fill}): {right:?}"); + // The transparent half shows the slide, not a shadow behind it. + assert!(img.pixel(200, 200)[0] > 240, "{as_fill}"); + } +} From 10d33333447d7a391a46891e442f0b321f35662f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 23:27:47 -0400 Subject: [PATCH 45/49] Render: text in a scaled group keeps its point size, as in PowerPoint (#340) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 28 ++++++++++++++++++++++++++++ crates/render/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c20700ca..2e13922f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -681,6 +681,24 @@ impl Renderer { if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) } } + /// The size and transform a shape's text is set in: inside a group whose child coordinates + /// scale the shape, the shape's size in slide points and `m` with that scale taken out, so + /// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves. + fn text_space(&self, m: Affine, depth: usize, w: f64, h: f64) -> (f64, f64, Affine) { + let (_, rel) = self.line_space(m, depth); + let c = rel.as_coeffs(); + let (sx, sy) = (c[0].hypot(c[1]), c[2].hypot(c[3])); + let ok = |k: f64| k.is_finite() && k > 1e-9; + if depth == 0 || !ok(sx) || !ok(sy) || ((sx - 1.0).abs() < 1e-9 && (sy - 1.0).abs() < 1e-9) { + return (w, h, m); + } + let (tw, th) = (w * sx, h * sy); + if !tw.is_finite() || !th.is_finite() { + return (w, h, m); + } + (tw, th, m * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy)) + } + fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) { return; @@ -921,6 +939,16 @@ impl Renderer { if let Some(body) = &s.text && !skip_text { + // Text in a group is set at the shape's size on the slide, in points: a group's + // child-coordinate scale sizes and places the box, never the type (#233's twin). + let (tw, th, m) = self.text_space(m, depth, w, h); + let text_geo; + let geo = if (tw, th) == (w, h) { + &geo + } else { + text_geo = shape_geometry(s, tw, th); + &text_geo + }; let tr = geo.text_rect; if !body.is_empty() { let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false }); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 5ccefb96..7c18f739 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1553,3 +1553,33 @@ fn a_picture_casts_its_shadow_from_its_pixels() { assert!(img.pixel(200, 200)[0] > 240, "{as_fill}"); } } + +#[test] +fn text_in_a_scaled_group_keeps_its_point_size() { + // A group's child coordinates scale its shapes' boxes, never their type: a 24 pt word in a + // group whose child space is 1/1600 of its size (as old decks write them) or 4 times it is + // 24 pt on the slide, and inside its box (it used to grow until the slide turned black). + let draw = |scale: f64| { + let mut p = Presentation::default(); + let mut body = TextBody::from_text("Hello"); + body.paragraphs[0].runs[0].props.size = Some(24.0); + body.paragraphs[0].runs[0].props.fill = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::BLACK))); + let child = Shape { id: ShapeId(601), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale)), text: Some(body), ..Default::default() }; + let g = Shape { + id: ShapeId(600), + xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), + kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale) }, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![g]; + render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let ((_, y0, _, y1), _) = ink(&draw(1.0)); + let plain = y1 - y0; + assert!(plain > 10 && plain < 30, "24 pt word ink height {plain}"); + for scale in [1600.0, 4.0, 0.25] { + let ((x0, sy0, x1, sy1), _) = ink(&draw(scale)); + assert!((sy1 - sy0).abs_diff(plain) <= 1, "scale {scale}: ink height {} vs {plain}", sy1 - sy0); + assert!(x0 >= 100 && x1 <= 500 && sy0 >= 100 && sy1 <= 300, "scale {scale}: ink inside the box"); + } +} From cdfaaf4a38622c4b407bea8df7fad4a7622c09f8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 23:28:37 -0400 Subject: [PATCH 46/49] Docs: text in scaled groups is drawn (#340) (#341) Co-authored-by: Claude Opus 5.5 (1M context) --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b1904811..1e888c7a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -63,7 +63,7 @@ the round trip even though DeckCraft doesn't model it. | Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept | | Notes slides and notes master | yes | yes | No notes-master editing view | | Handout master | no | no | | -| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group keep their width in points whatever the group's child-coordinate scale (#233); text in a group with a tiny child space is not drawn yet. | +| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | | | Lines: dash, compound, caps, joins, arrowheads | yes | yes | | | Effects: shadow, glow, soft edges, reflection | yes | yes | | From a68da9f51dd0265b45150a7cd02c1042df88a23f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 23:50:02 -0400 Subject: [PATCH 47/49] Effects: fill overlays (a:fillOverlay) blend over the shape's fill, and round-trip (#342) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/model/src/style.rs | 11 +++++ crates/pptx/src/read/dml.rs | 8 ++++ crates/pptx/src/write/dml.rs | 82 +++++++++++++++++++++--------------- crates/pptx/tests/fills.rs | 31 ++++++++++++++ crates/render/src/lib.rs | 19 +++++++++ crates/render/src/tests.rs | 31 ++++++++++++++ 6 files changed, 148 insertions(+), 34 deletions(-) diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index ff1b4e00..bb253974 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -387,6 +387,16 @@ pub struct Effects { pub bevel: Option, /// Unmodelled 3-D XML kept for round-trip (`scene3d`, `sp3d`). pub raw3d: Option, + /// `a:fillOverlay`: a fill blended over the shape's fill (`blend`: `over`, `mult`, `screen`, + /// `darken`, `lighten`). Only solid and gradient overlays are kept. + pub fill_overlay: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FillOverlay { + pub blend: String, + pub fill: Fill, } impl Effects { @@ -397,6 +407,7 @@ impl Effects { && self.soft_edge.is_none() && self.reflection.is_none() && self.bevel.is_none() + && self.fill_overlay.is_none() } } diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index c902776d..7bec2e63 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -257,6 +257,14 @@ pub fn effects(el: &El) -> Effects { "innerShdw" => fx.inner_shadow = Some(shadow(e, true)), "glow" => fx.glow = Some(Glow { color: color(e).unwrap_or(ColorRef::rgb(Rgba::BLACK)), radius: pt(e, "rad").unwrap_or(0.0).max(0.0) }), "softEdge" => fx.soft_edge = Some(pt(e, "rad").unwrap_or(0.0).max(0.0)), + "fillOverlay" => { + let fill = e.elements().find_map(|f| match f.local() { + "solidFill" => color(f).map(|c| Fill::Solid { color: c }), + "gradFill" => Some(Fill::Gradient(gradient(f))), + _ => None, + }); + fx.fill_overlay = fill.map(|fill| deckcraft_model::style::FillOverlay { blend: e.attr("blend").unwrap_or("over").to_string(), fill }); + } "reflection" => { fx.reflection = Some(Reflection { blur: pt(e, "blurRad").unwrap_or(0.0), diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 6761f0bf..7bae42d9 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -76,40 +76,7 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) { match f { Fill::None => w.empty0("a:noFill"), Fill::Solid { color: c } => solid(w, c), - Fill::Gradient(g) => { - w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" })); - // No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill. - let mut stops = g.stops.clone(); - if stops.len() == 1 { - let mut s = stops[0].clone(); - s.pos = 1.0; - stops.push(s); - } - if !stops.is_empty() { - w.open0("a:gsLst"); - for s in &stops { - w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0)))); - color(w, &s.color); - w.close("a:gs"); - } - w.close("a:gsLst"); - } - match &g.shape { - GradientShape::Linear { angle, scaled } => { - w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" })) - } - GradientShape::Path { path, focus } => { - let p = match path.as_str() { - "rect" | "shape" | "circle" => path.as_str(), - _ => "circle", - }; - w.open("a:path", A::new().a("path", p)); - w.empty("a:fillToRect", A::new().a("l", pct(focus[0])).a("t", pct(focus[1])).a("r", pct(focus[2])).a("b", pct(focus[3]))); - w.close("a:path"); - } - } - w.close("a:gradFill"); - } + Fill::Gradient(g) => gradient(w, g), Fill::Picture(pf) => blip_fill(w, x, o, "a:blipFill", pf), Fill::Pattern(p) => { w.open("a:pattFill", A::new().a("prst", if p.preset.is_empty() { "pct5" } else { &p.preset })); @@ -275,8 +242,55 @@ fn shadow(w: &mut W, tag: &str, s: &deckcraft_model::style::Shadow) { w.close(tag); } +fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) { + w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" })); + // No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill. + let mut stops = g.stops.clone(); + if stops.len() == 1 { + let mut s = stops[0].clone(); + s.pos = 1.0; + stops.push(s); + } + if !stops.is_empty() { + w.open0("a:gsLst"); + for s in &stops { + w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0)))); + color(w, &s.color); + w.close("a:gs"); + } + w.close("a:gsLst"); + } + match &g.shape { + GradientShape::Linear { angle, scaled } => w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" })), + GradientShape::Path { path, focus } => { + let p = match path.as_str() { + "rect" | "shape" | "circle" => path.as_str(), + _ => "circle", + }; + w.open("a:path", A::new().a("path", p)); + w.empty("a:fillToRect", A::new().a("l", pct(focus[0])).a("t", pct(focus[1])).a("r", pct(focus[2])).a("b", pct(focus[3]))); + w.close("a:path"); + } + } + w.close("a:gradFill"); +} + pub fn effects(w: &mut W, fx: &Effects) { w.open0("a:effectLst"); + // Schema order: blur, fillOverlay, glow, innerShdw, outerShdw, prstShdw, reflection, softEdge. + if let Some(ov) = &fx.fill_overlay { + let blend = match ov.blend.as_str() { + b @ ("over" | "mult" | "screen" | "darken" | "lighten") => b, + _ => "over", + }; + w.open("a:fillOverlay", A::new().a("blend", blend)); + match &ov.fill { + Fill::Solid { color: c } => solid(w, c), + Fill::Gradient(g) => gradient(w, g), + _ => w.empty0("a:noFill"), + } + w.close("a:fillOverlay"); + } if let Some(g) = &fx.glow { w.open("a:glow", A::new().a("rad", emu(g.radius).max(0))); color(w, &g.color); diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs index 956af5fd..b129e646 100644 --- a/crates/pptx/tests/fills.rs +++ b/crates/pptx/tests/fills.rs @@ -102,3 +102,34 @@ fn a_gradient_without_stops_is_saved_without_stops() { let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape"); assert_eq!(s.fill, Some(Fill::Gradient(own))); } + +#[test] +fn a_fill_overlay_round_trips() { + use deckcraft_model::style::{FillOverlay, Gradient, GradientShape, GradientStop}; + let mut p = defaults::new_presentation(None); + let id = ShapeId(p.alloc_id()); + let first = p.slides[0].id; + let overlay = FillOverlay { + blend: "mult".into(), + fill: Fill::Gradient(Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1) }, + GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(10, 20, 30)) }, + ], + shape: GradientShape::Linear { angle: 4.5, scaled: true }, + rotate_with_shape: true, + }), + }; + p.slide_mut(first).expect("slide").shapes.push(Shape { + id, + name: "Overlaid".into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)), + fill: Some(Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::WHITE))), + effects: Some(deckcraft_model::Effects { fill_overlay: Some(overlay.clone()), ..Default::default() }), + ..Default::default() + }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + let s = q.slides[0].shapes.iter().find(|s| s.name == "Overlaid").expect("shape"); + assert_eq!(s.effects.as_ref().and_then(|e| e.fill_overlay.clone()), Some(overlay)); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2e13922f..47a0f1e2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -872,6 +872,25 @@ impl Renderer { } } } + // `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply + // a gradient over a tiled picture), inside the filled outline, under the line. + if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref()) + && fill.as_ref().is_some_and(|f| !f.is_none()) + { + let mix = match ov.blend.as_str() { + "mult" => peniko::Mix::Multiply, + "screen" => peniko::Mix::Screen, + "darken" => peniko::Mix::Darken, + "lighten" => peniko::Mix::Lighten, + _ => peniko::Mix::Normal, + }; + for (i, sp) in geo.paths.iter().enumerate().filter(|(i, sp)| sp.fill != FillMode::None && fade(*i).fill > 0.0) { + ctx.set_transform(m); + ctx.push_layer(Some(&sp.path), Some(peniko::BlendMode::new(mix, peniko::Compose::SrcOver)), None, None, None); + paint::fill_path(ctx, rctx, &ov.fill, fx_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, FillMode::Norm, fade(i).fill, f.pres); + ctx.pop_layer(); + } + } // Outline. if line.fill.as_ref().is_some_and(|f| !f.is_none()) { let lw = line.width.unwrap_or(0.75).max(0.0); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 7c18f739..7fd3db5c 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1583,3 +1583,34 @@ fn text_in_a_scaled_group_keeps_its_point_size() { assert!(x0 >= 100 && x1 <= 500 && sy0 >= 100 && sy1 <= 300, "scale {scale}: ink inside the box"); } } + +#[test] +fn a_fill_overlay_blends_over_the_fill() { + // `a:fillOverlay blend="mult"`: a 50% grey multiplied over a light fill darkens it; "screen" + // lightens a dark one; the overlay stays inside the shape. + use deckcraft_model::style::FillOverlay; + let draw = |base: [u8; 3], blend: &str| { + let mut p = Presentation::default(); + let mut sh = Shape { + id: ShapeId(600), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(base[0], base[1], base[2])))), + line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), + ..Default::default() + }; + sh.effects = Some(deckcraft_model::Effects { + fill_overlay: Some(FillOverlay { blend: blend.into(), fill: Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(128, 128, 128))) }), + ..Default::default() + }); + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + (img.pixel(200, 150), img.pixel(50, 50)) + }; + let (mult, outside) = draw([200, 100, 50], "mult"); + assert!((mult[0] as i32 - 100).abs() <= 3 && (mult[1] as i32 - 50).abs() <= 3 && (mult[2] as i32 - 25).abs() <= 3, "{mult:?}"); + assert_eq!(&outside[..3], &[255, 255, 255], "nothing outside the shape"); + let (screen, _) = draw([0, 0, 0], "screen"); + assert!((screen[0] as i32 - 128).abs() <= 3, "{screen:?}"); + let (over, _) = draw([200, 100, 50], "over"); + assert!((over[0] as i32 - 128).abs() <= 3 && (over[1] as i32 - 128).abs() <= 3, "{over:?}"); +} From f6c143ac85f1b4ded87b3e23c3ec9163fec5c238 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 23:52:01 -0400 Subject: [PATCH 48/49] Text: symbol-font characters draw in the run's a:sym font (#343) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 19 ++++++++++++++++--- crates/text/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index cc09349a..d2ee9768 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -201,6 +201,9 @@ struct Style { /// The `a:ea` and `a:cs` families (theme references resolved), if the document names one. ea: Option, cs: Option, + /// The `a:sym` family: private-use characters (U+F000–U+F0FF, as PowerPoint stores symbol-font + /// characters) are drawn in it. + sym: Option, /// Heading font (`+mj-…`): the theme's major font set applies. major: bool, /// Pair kerning on: `a:rPr/@kern` is set and the size reaches it. @@ -315,6 +318,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option) -> Styl lang: r.lang.clone().filter(|l| !l.is_empty()), ea, cs, + sym: r.font_sym.as_deref().map(|f| ctx.master.theme.font(f)).filter(|f| !f.trim().is_empty()), major, kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale), } @@ -588,9 +592,18 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa // Emoji, and pictographs asked to look like text (U+FE0E), pick their own face. let neutral = cluster.iter().all(|c| script::script_of(*c).is_none()) && !deckcraft_fonts::is_emoji_cluster(cluster) && !cluster.contains(&'\u{FE0E}'); - let face = match prev.as_ref() { - Some(p) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(), - _ => { + // A symbol-font character (U+F0xx) with an `a:sym` font draws in that font when it's there. + let sym_face = st + .sym + .as_deref() + .filter(|_| cluster.first().is_some_and(|c| ('\u{F000}'..='\u{F0FF}').contains(c))) + .filter(|f| db.has_family(f)) + .map(|f| db.face(f, st.style_name)) + .filter(|f| deckcraft_fonts::covers_cluster(f, cluster)); + let face = match (sym_face, prev.as_ref()) { + (Some(f), _) => f, + (None, Some(p)) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(), + (None, _) => { let chain = chains.entry((si, slot, sc)).or_insert_with(|| face_chain(ctx, st, slot, sc)); db.cascade(cluster, chain, st.style_name) } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 930789b9..70a7f132 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -759,3 +759,25 @@ fn symbol_char_maps_symbol_font_codes() { assert_eq!(symbol_char(&face, "Wingdings", '\u{F06E}'), Some('\u{25A0}')); assert_eq!(symbol_char(&face, "Wingdings", '\u{100}'), None, "not a symbol code"); } + +#[test] +fn symbol_font_characters_draw_in_the_sym_font() { + // PowerPoint stores a Symbol-font character as U+F0xx with ``: + // U+F0DE is the Symbol font's double arrow (⇒). It must come from that font, not from + // whichever installed font happens to map U+F0DE (Wingdings draws an arrow at another angle). + let symbol = "Symbol"; + if !FontDb::global().has_family(symbol) { + eprintln!("skipped: no Symbol font installed"); + return; + } + let (p, mut sh) = setup("x\u{F0DE}y", 600.0, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + para.props.bullet = Some(Bullet::None); + para.runs[0].props.font = Some("Arial".into()); + para.runs[0].props.font_sym = Some(symbol.into()); + let l = lay(&p, &sh, 600.0, 300.0); + let families: Vec<(String, (usize, usize))> = l.runs.iter().map(|r| (r.face.family.clone(), r.chars)).collect(); + assert!(families.iter().any(|(f, c)| f.eq_ignore_ascii_case(symbol) && c.0 == 1), "{families:?}"); + // The letters around it keep the run's font. + assert!(families.iter().all(|(f, c)| c.0 == 1 || !f.eq_ignore_ascii_case(symbol)), "{families:?}"); +} From 1660982e58ee48fd9f9bef6dc2620d78ada04613 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 23:56:02 -0400 Subject: [PATCH 49/49] File tab: PowerPoint's File view (Home, New, Open, Info, Save As, Print, Export, Close) (#76) (#344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Engine: file.info and PowerPoint-style print slide ranges (#76) file.info reports what File › Info shows (name, path, format, unsaved changes, slide and hidden-slide counts, sections, size on disk, properties). file.export's PDF `slides` also takes the range typed in PowerPoint's Print page ("1-3, 5", 1-based); bad or out-of-range text is an error instead of a silent full export. Co-Authored-By: Claude Opus 5.5 (1M context) * UI: the File tab (Backstage) like PowerPoint's (#76) File (first ribbon tab) or Alt+F opens a full-window view in place of the ribbon, slides and status bar: a side menu (Back, Home, New, Open, Info, Save, Save As, Print, Export, Close; Account and Options at the bottom) and the chosen page. Esc or Back returns; Up/Down walk the side menu and show each page as it gets the focus; Enter runs Save/Close/Options. Laid out after PowerPoint for Windows as observed: 160-pt menu a step darker than the page, outline icons, current page in the accent colour. The start screen is now File › Home (as in PowerPoint), with only Home, New and Open at the top and no Back. Pages call existing commands and file helpers: file.new {theme} and the sample deck (Home, New); recent files with search, Browse and Recover Unsaved (Open); file.info and file.properties, Accessibility Checker, Revert (Info); file.save / file.saveAs / file.export by type (Save, Save As); a PDF with the Print page's slides, layout (full page, notes, handouts 1-9), hidden slides and frames, opened in the system viewer or downloaded on the web, with a page preview (Print, Ctrl+P); Create PDF, Create Handouts and Change File Type (Export); appearance and About (Account); Preferences (Options). Share (cloud) is left out. Close and Ctrl+W now ask Save / Don't Save / Cancel about unsaved changes instead of closing at once. The Export dialog shares SlideApp::export_as with the Export page. New UI commands: app.backstage {page}, app.closePresentation, app.print; ui.inspect reports the page. New icons drawn in code: back, save as, account, recent, new file, open folder. Co-Authored-By: Claude Opus 5.5 (1M context) * Docs: File tab (Backstage) in ui-parity, gaps, checklist and progress log (#76) Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ROADMAP.md | 2 + crates/engine/src/cmd/file.rs | 81 +- crates/engine/src/tests.rs | 61 ++ crates/ui-egui/src/backstage.rs | 1245 +++++++++++++++++++++++++ crates/ui-egui/src/backstage_tests.rs | 438 +++++++++ crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/control.rs | 3 +- crates/ui-egui/src/dialogs.rs | 204 +--- crates/ui-egui/src/icons.rs | 34 + crates/ui-egui/src/lib.rs | 238 ++++- crates/ui-egui/src/menus.rs | 22 +- crates/ui-egui/src/ribbon.rs | 19 +- docs/control-protocol.md | 2 +- docs/gaps.md | 4 +- docs/parity-checklist.md | 8 +- docs/ui-parity.md | 36 +- 16 files changed, 2178 insertions(+), 221 deletions(-) create mode 100644 crates/ui-egui/src/backstage.rs create mode 100644 crates/ui-egui/src/backstage_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 28c52d95..2dba0e27 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,6 +133,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | | 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection | | 2026-10-12 | EMF/WMF pictures draw their text (fonts, alignment, dx advances, escapement, symbol fonts), pattern and hatch brushes, PATBLT and the XOR/AND see-through shadow; legacy OLE objects show their VML preview and ActiveX controls their pictures | +| 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes | | 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | @@ -146,6 +147,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials | | 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index cab58058..f6424f7c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -15,7 +15,16 @@ pub fn specs() -> Vec { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as), cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd), - cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export), + cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export), + cmd!( + query "file.info", + "Info", + ["File"], + None, + "{} → {title, path, format, dirty, slides, hiddenSlides, sections, bytes?, properties}", + has_doc, + info + ), cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render), cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close), cmd!( @@ -118,26 +127,59 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result> { } } -/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides: [i], -/// includeHidden, textLayer, frame}`. -pub fn pdf_options(p: &Value) -> deckcraft_pdf::PdfOptions { +/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides, +/// includeHidden, textLayer, frame}` for a presentation of `count` slides. `slides` is a list of +/// 0-based indices or a range as typed in PowerPoint's Print page (`"1-3, 5"`, 1-based). +pub fn pdf_options(p: &Value, count: usize) -> Result { let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() }; o.layout = match str_param(p, "layout").unwrap_or("slides") { "notes" => deckcraft_pdf::PageLayout::Notes, - "handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).min(9) as u8 }, + "handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 }, _ => deckcraft_pdf::PageLayout::Slides, }; o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0); - o.slides = p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).collect()); + o.slides = match p.get("slides") { + Some(Value::Array(a)) => Some(a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).filter(|i| *i < count).collect()), + Some(Value::String(text)) if !text.trim().is_empty() => Some(parse_slide_range(text, count)?), + _ => None, + }; o.include_hidden = bool_or(p, "includeHidden", false); o.text_layer = bool_or(p, "textLayer", true); o.frame_slides = bool_or(p, "frame", true); - o + Ok(o) +} + +/// A slide range as typed in the Print page's Slides box (`"1-3, 5"`; 1-based, either separator +/// `,` or `;`, ranges either way round) → 0-based indices in order, without repeats. Numbers past +/// the last slide or a range that isn't numbers is an error, as in PowerPoint. +pub fn parse_slide_range(text: &str, count: usize) -> Result> { + let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5")); + let mut out: Vec = vec![]; + for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) { + let (a, b) = match part.split_once('-') { + Some((a, b)) => (a.trim(), b.trim()), + None => (part, part), + }; + let (a, b) = (a.parse::().map_err(|_| invalid())?, b.parse::().map_err(|_| invalid())?); + let (lo, hi) = (a.min(b), a.max(b)); + if lo == 0 || hi > count { + return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them"))); + } + for i in lo - 1..hi { + if !out.contains(&i) { + out.push(i); + } + } + } + if out.is_empty() { + return Err(invalid()); + } + Ok(out) } /// PDF bytes for `doc` with [`pdf_options`] from `p`. pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result> { - deckcraft_pdf::export(doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string())) + deckcraft_pdf::export(doc, &pdf_options(p, doc.slides.len())?).map_err(|e| EngineError::Other(e.to_string())) } pub fn format_for_path(path: &str) -> &'static str { @@ -264,7 +306,7 @@ fn export(s: &mut Session, p: &Value) -> Result { Ok(json!({"files": written})) } "pdf" => { - let bytes = deckcraft_pdf::export(&st.doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))?; + let bytes = pdf_bytes(&st.doc, p)?; write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) } @@ -276,6 +318,27 @@ fn export(s: &mut Session, p: &Value) -> Result { } } +/// File › Info: what the Info page shows about the active presentation. +fn info(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let doc = &st.doc; + #[cfg(not(target_arch = "wasm32"))] + let bytes = st.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).map(|m| m.len()); + #[cfg(target_arch = "wasm32")] + let bytes: Option = None; + Ok(json!({ + "title": st.title(), + "path": st.path, + "format": st.path.as_deref().map(format_for_path), + "dirty": st.is_dirty(), + "slides": doc.slides.len(), + "hiddenSlides": doc.slides.iter().filter(|sl| sl.hidden).count(), + "sections": doc.sections.len(), + "bytes": bytes, + "properties": serde_json::to_value(&doc.props).unwrap_or_default(), + })) +} + fn close(s: &mut Session, _p: &Value) -> Result { if let Some(i) = s.active_index() { s.close_document(i); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 673f8ec3..e83f72ef 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -774,3 +774,64 @@ fn ime_composition_underlines_follow_the_text() { s.commit_text("日本語").unwrap(); assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty()); } + +/// File › Info (#76): the active presentation's name, location, counts and properties. +#[test] +fn file_info_reports_the_presentation() { + let mut s = session(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.go", &json!({"index": 1})).unwrap(); + s.execute("slide.hide", &json!({})).unwrap(); + s.execute("file.properties", &json!({"title": "Quarterly", "author": "Ada"})).unwrap(); + let v = s.execute("file.info", &json!({})).unwrap(); + assert_eq!(v["slides"], 3); + assert_eq!(v["hiddenSlides"], 1); + assert_eq!(v["path"], serde_json::Value::Null); + assert_eq!(v["dirty"], true); + assert_eq!(v["properties"]["title"], "Quarterly"); + assert_eq!(v["properties"]["author"], "Ada"); + assert!(v["title"].as_str().is_some_and(|t| !t.is_empty())); + // A query: no undo step, and nothing changes. + let before = s.doc().unwrap().history.undo.len(); + s.execute("file.info", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before); + // Saved to disk, it reports where and how big. + let dir = std::env::temp_dir().join(format!("deckcraft-info-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Info test.pptx").to_string_lossy().to_string(); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + let v = s.execute("file.info", &json!({})).unwrap(); + assert_eq!(v["path"], path.as_str()); + assert_eq!(v["format"], "pptx"); + assert_eq!(v["dirty"], false); + assert!(v["bytes"].as_u64().is_some_and(|b| b > 0)); + let _ = std::fs::remove_dir_all(&dir); + // Nothing open: an error, not a panic. + let mut empty = Session::new(); + assert!(empty.execute("file.info", &json!({})).is_err()); +} + +/// The Print page's Slides box (#76): PowerPoint-style ranges, 1-based. +#[test] +fn slide_ranges_parse_like_powerpoints_print_box() { + use cmd::file::parse_slide_range as r; + assert_eq!(r("1-3, 5", 6).unwrap(), vec![0, 1, 2, 4]); + assert_eq!(r("3-1;2", 6).unwrap(), vec![0, 1, 2], "reversed ranges and repeats"); + assert_eq!(r(" 6 ", 6).unwrap(), vec![5]); + assert!(r("7", 6).is_err(), "past the last slide"); + assert!(r("0", 6).is_err()); + assert!(r("a-b", 6).is_err()); + assert!(r(" , ", 6).is_err()); + assert!(r("1-99999999999999999999999", 6).is_err(), "hostile numbers are refused"); + // file.export takes the same text for a PDF. + let mut s = session(); + s.execute("slide.new", &json!({})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let one = cmd::file::pdf_bytes(&doc, &json!({"slides": "2"})).unwrap(); + let both = cmd::file::pdf_bytes(&doc, &json!({"slides": "1-2"})).unwrap(); + assert!(one.len() < both.len()); + assert!(cmd::file::pdf_bytes(&doc, &json!({"slides": "5"})).is_err()); + let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); + assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs new file mode 100644 index 00000000..f1d0fde3 --- /dev/null +++ b/crates/ui-egui/src/backstage.rs @@ -0,0 +1,1245 @@ +//! The File tab (Backstage, #76): a full-window view that replaces the ribbon, slides and status +//! bar until Esc or the back arrow returns to the presentation. With nothing open it is the start +//! screen: PowerPoint's start screen and File › Home are the same page. +//! +//! A side menu on the left picks a page (Home, New, Open, Info, Save As, Print, Export, Account); +//! Save, Close and Options act at once. Every action runs an existing command through +//! [`SlideApp::run`] or one of the app's file helpers ([`SlideApp::export_as`], +//! [`SlideApp::print_pdf`], [`SlideApp::save_as_dialog`]); the state here is UI-only and never +//! saved. Recent presentations are listed by path: nothing on disk is read until one is picked. +//! +//! Laid out after PowerPoint for Windows (observed black-box): a 160-point side menu darker than +//! the page, 40-point entries with an outline icon, the current page in the accent colour. + +use egui::{Align2, Color32, CornerRadius, Key, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::SlideApp; +use crate::icons::{self, Icon}; +use crate::theme::{self, Tokens}; + +/// Width of the side menu. +pub const NAV_W: f32 = 160.0; +/// Height of a side-menu entry (the back arrow's row is a little taller). +const ITEM_H: f32 = 40.0; +const BACK_H: f32 = 46.0; +/// The widest a page's content grows. +const CONTENT_W: f32 = 1180.0; + +/// A page of the File tab. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Page { + Home, + New, + Open, + Info, + SaveAs, + Print, + Export, + Account, +} + +impl Page { + pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Account]; + + /// The id `app.backstage {page}` takes. + pub fn id(self) -> &'static str { + match self { + Page::Home => "home", + Page::New => "new", + Page::Open => "open", + Page::Info => "info", + Page::SaveAs => "saveAs", + Page::Print => "print", + Page::Export => "export", + Page::Account => "account", + } + } + + pub fn parse(id: &str) -> Option { + let id = id.trim(); + Page::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id)) + } + + /// Side-menu label and page heading. + pub fn label(self) -> &'static str { + match self { + Page::Home => "Home", + Page::New => "New", + Page::Open => "Open", + Page::Info => "Info", + Page::SaveAs => "Save As", + Page::Print => "Print", + Page::Export => "Export", + Page::Account => "Account", + } + } + + fn icon(self) -> Icon { + match self { + Page::Home => Icon::Home, + Page::New => Icon::NewFile, + Page::Open => Icon::OpenFolder, + Page::Info => Icon::Info, + Page::SaveAs => Icon::SaveAs, + Page::Print => Icon::Print, + Page::Export => Icon::Export, + Page::Account => Icon::Account, + } + } + + /// Pages about the open presentation (hidden on the start screen). + pub fn needs_doc(self) -> bool { + matches!(self, Page::Info | Page::SaveAs | Page::Print | Page::Export) + } +} + +/// A side-menu entry: a page, or an action that runs at once. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Nav { + Page(Page), + Save, + Close, + Options, +} + +impl Nav { + fn label(self) -> &'static str { + match self { + Nav::Page(p) => p.label(), + Nav::Save => "Save", + Nav::Close => "Close", + Nav::Options => "Options", + } + } + fn icon(self) -> Icon { + match self { + Nav::Page(p) => p.icon(), + Nav::Save => Icon::Save, + Nav::Close => Icon::File, + Nav::Options => Icon::Settings, + } + } +} + +/// The side menu, top group then bottom group, in keyboard order. PowerPoint's Share (cloud) is +/// left out; with nothing open only Home, New and Open stay at the top. +pub fn entries(has_doc: bool) -> (Vec