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 }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c8137dfb..9ca8e5e8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,6 +27,10 @@ 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/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/Cargo.lock b/Cargo.lock index 6b839d2b..f4d53470 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", @@ -1046,28 +1046,37 @@ 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.0" +version = "0.6.1" dependencies = [ "deckcraft-color", "deckcraft-geom", "serde", "serde_json", "thiserror 2.0.21", + "unicode-segmentation", ] [[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 +1091,7 @@ dependencies = [ [[package]] name = "deckcraft-pptx" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-color", "deckcraft-engine", @@ -1098,23 +1107,25 @@ dependencies = [ [[package]] name = "deckcraft-render" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-color", "deckcraft-fonts", "deckcraft-geom", + "deckcraft-metafile", "deckcraft-model", "deckcraft-text", "image", "kurbo", "log", + "skrifa 0.47.0", "vello_common 0.2.0", "vello_cpu 0.2.0", ] [[package]] name = "deckcraft-text" -version = "0.6.0" +version = "0.6.1" dependencies = [ "chrono", "deckcraft-color", @@ -1128,7 +1139,7 @@ dependencies = [ [[package]] name = "deckcraft-ui-egui" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1150,7 +1161,7 @@ dependencies = [ [[package]] name = "deckcraft-vp9" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1159,7 +1170,7 @@ dependencies = [ [[package]] name = "deckcraft-web" -version = "0.6.0" +version = "0.6.1" dependencies = [ "deckcraft-engine", "deckcraft-ui-egui", @@ -5655,7 +5666,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..b8596156 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" @@ -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/ROADMAP.md b/ROADMAP.md index 801ddd84..2dba0e27 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,14 @@ 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 | 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) | | 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 | @@ -139,6 +147,8 @@ 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% | | 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha | 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 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/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/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 2a2fed78..e84249fc 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!( @@ -83,7 +92,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"))] @@ -93,7 +105,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 { @@ -105,7 +118,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`). @@ -118,26 +131,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 +310,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 +322,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/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/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/cmd/text.rs b/crates/engine/src/cmd/text.rs index 48be3a51..1b2cb2d2 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -17,7 +17,38 @@ 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!( + "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), @@ -144,6 +175,35 @@ 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 }, + ); + // 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))) +} + 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)) @@ -184,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]})) } @@ -340,6 +400,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) @@ -349,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 @@ -382,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()?; @@ -391,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() { @@ -427,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 { @@ -479,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/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/keys.rs b/crates/engine/src/keys.rs index 966a109b..665cc22e 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -20,28 +20,49 @@ 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`, `]`). /// 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(); - 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" => { @@ -116,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", @@ -126,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 { @@ -136,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()?; @@ -151,6 +220,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 a2d2d2ac..c2b56c7f 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; @@ -67,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 { @@ -76,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). @@ -130,6 +139,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, @@ -141,6 +168,11 @@ 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, + /// An input method's composition in progress ([`Composition`]). + pub composition: Option, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -158,6 +190,8 @@ impl DocState { interaction: None, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), title, + missing_fonts: Vec::new(), + composition: None, } } pub fn is_dirty(&self) -> bool { @@ -313,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 { @@ -340,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. @@ -398,6 +437,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 { @@ -448,10 +491,24 @@ 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()?; 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; @@ -496,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.rs b/crates/engine/src/tests.rs index 3864a34e..6fb45238 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. @@ -544,6 +568,274 @@ fn inserted_hyperlinks_look_like_links() { 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]); +} + +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()); +} + +/// 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"); +} + /// Each slide as (title, [(level, bullet text)]). fn outline_of(p: &deckcraft_model::Presentation) -> Vec<(String, Vec<(u8, String)>)> { p.slides 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/engine/src/tools.rs b/crates/engine/src/tools.rs index 9a33933a..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) { @@ -265,6 +270,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 { @@ -419,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)); @@ -429,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]})); @@ -460,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/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 f0a06efe..31cb9a18 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, @@ -79,6 +130,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. @@ -87,6 +142,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 @@ -154,6 +211,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() } @@ -242,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, @@ -254,6 +321,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] = &[ @@ -298,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. @@ -344,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() } @@ -361,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"] { @@ -375,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)); @@ -436,6 +535,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, @@ -456,10 +572,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, @@ -467,6 +585,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, @@ -474,6 +593,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(), }) } @@ -534,6 +654,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() }), } } @@ -639,6 +761,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())); @@ -719,17 +843,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)? @@ -745,7 +887,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. @@ -800,11 +944,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()); } @@ -880,6 +1040,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 94e8081f..16be2ca5 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -13,8 +13,10 @@ mod fontdb; pub mod script; mod shape; -pub use fallback::{bundled_families, covers_cluster, ligature_carets}; -pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs}; +pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets}; +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; @@ -136,6 +138,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(); @@ -143,6 +164,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()); @@ -152,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 00000000..ab660c65 Binary files /dev/null and b/crates/fonts/tests/fixtures/legacy-regular.ttf differ diff --git a/crates/fonts/tests/fixtures/legacy-semibold.ttf b/crates/fonts/tests/fixtures/legacy-semibold.ttf new file mode 100644 index 00000000..44b0ec7c Binary files /dev/null and b/crates/fonts/tests/fixtures/legacy-semibold.ttf differ 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/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/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/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/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..4ffa56bf --- /dev/null +++ b/crates/metafile/src/canvas.rs @@ -0,0 +1,893 @@ +//! 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 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; +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; +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 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; +/// 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, 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>, +} + +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), + 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] +} + +/// 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, + }, + /// 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)] +struct Saved { + map: Mapping, + pen: Pen, + brush: Brush, + even_odd: bool, + rop2: u32, + text: TextState, +} + +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, + /// 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, + 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::new(BS_SOLID, [255, 255, 255, 255]), + even_odd: true, + 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(), + 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::(), + // 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; + } 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. 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.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). + /// 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.clone(), + even_odd: self.even_odd, + rop2: self.rop2, + text: self.text.clone(), + }); + } 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.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 { + 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).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 => {} + } + } + + fn select_stock(&mut self, i: u32) { + let white = [255, 255, 255, 255]; + let black = [0, 0, 0, 255]; + let gray = |v: u8| Brush::new(BS_SOLID, [v, v, v, 255]); + match i { + 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::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, + _ => {} + } + } + + 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, + } + } + + /// 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)> { + 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.mix_skips(fill) || dev.len() < 2 { + return; + } + let fill = if fill { self.fill_paint() } else { None }; + let stroke = if stroke { self.stroke_pen() } else { None }; + self.push_shape(dev, fill, stroke); + } + + 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.mix_skips(fill) || ops.len() < 2 { + return; + } + let fill = if fill { self.fill_paint() } else { None }; + let stroke = if stroke { self.stroke_pen() } else { None }; + self.push_shape(ops, fill, stroke); + } + + /// 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). + 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]), + _ => {} + } + } + + /// 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) { + 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 }); + } +} + +/// 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 { + 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..709537f7 --- /dev/null +++ b/crates/metafile/src/emf.rs @@ -0,0 +1,433 @@ +//! 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_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; +/// 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), + 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)), + 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)?; + 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(()) +} + +/// 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..d7d98501 --- /dev/null +++ b/crates/metafile/src/lib.rs @@ -0,0 +1,275 @@ +//! 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, 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`], +//! 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 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; +/// 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 }, + /// 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)] +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) { + // 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); + }; + 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); + // 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::>>()?; + 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 }) + } + 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 } +} + +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, .. } | Raw::Pattern { 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])); + } + 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 new file mode 100644 index 00000000..28e0b6ae --- /dev/null +++ b/crates/metafile/src/place.rs @@ -0,0 +1,150 @@ +//! 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 }, + /// `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 { + /// 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::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))); + 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..271cc83b --- /dev/null +++ b/crates/metafile/src/tests.rs @@ -0,0 +1,1520 @@ +//! 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), + other => panic!("expected a path: {other:?}"), + } +} + +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}"); +} + +// ---- 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 new file mode 100644 index 00000000..12733e1c --- /dev/null +++ b/crates/metafile/src/wmf.rs @@ -0,0 +1,380 @@ +//! 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::{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) { + 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); + cv.pattern_px = PT_PER_PX; + } + 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), + 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. + 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. +fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> { + let style = u32::from(r.u16(6)?); + let color = r.u32(8)?; + 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(()) +} + +/// (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/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/defaults.rs b/crates/model/src/defaults.rs index 8a2b5b4c..8432ce4a 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, }); }; @@ -511,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/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 6940fa4b..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; @@ -443,6 +444,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")] @@ -488,6 +529,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 +552,7 @@ impl Default for Slide { animations: vec![], notes: TextBody::default(), comments: vec![], + color_map: None, raw_ext: None, } } @@ -596,6 +642,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 +691,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 +711,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 { @@ -778,6 +839,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/resolve.rs b/crates/model/src/resolve.rs index 8769b571..acaff2b5 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(), }; @@ -130,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 { @@ -142,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 { @@ -219,6 +238,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/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/model/src/table.rs b/crates/model/src/table.rs index 45de90b4..2dd46e3b 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)] @@ -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, } } } @@ -199,6 +203,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/model/src/tests.rs b/crates/model/src/tests.rs index c34fa17f..61a8922d 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(); @@ -159,3 +182,51 @@ 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)); +} + +#[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/model/src/text.rs b/crates/model/src/text.rs index addbdff1..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); @@ -223,6 +228,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 +262,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/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/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index b4f08e0d..7bec2e63 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, }, @@ -251,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), @@ -315,13 +329,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))); @@ -339,6 +385,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/read/mod.rs b/crates/pptx/src/read/mod.rs index b8c403b5..057614eb 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, }; @@ -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(); @@ -387,6 +397,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 +430,154 @@ 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); + } + } +} + +/// `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") { @@ -523,12 +682,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 +701,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(); @@ -545,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 2e10e362..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; @@ -104,6 +120,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 +150,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") { @@ -180,6 +208,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 +266,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); } @@ -346,7 +384,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") { @@ -360,6 +399,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")?; @@ -384,6 +438,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); @@ -391,7 +446,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/dml.rs b/crates/pptx/src/write/dml.rs index eddb618e..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" })); - w.open0("a:gsLst"); - 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"); - } - 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); @@ -392,7 +406,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 { @@ -432,6 +458,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/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 5d15246d..7fb6a713 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>) { @@ -497,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); @@ -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/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 9b7309c0..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"); @@ -494,6 +510,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/fills.rs b/crates/pptx/tests/fills.rs new file mode 100644 index 00000000..b129e646 --- /dev/null +++ b/crates/pptx/tests/fills.rs @@ -0,0 +1,135 @@ +//! 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("