diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..2408084c --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..2c4b8cb1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587..01cb9cd6 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 00000000..ecee45a6 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index e2f12d9d..25d12b49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,6 +131,7 @@ Standard: craftrules `standards/never-crash.md`. CLI, control channel and MCP do exactly the same thing. - **The UI is thin**: panels read engine state and act through commands. Colours come from theme tokens. **Never use Tauri**; egui only. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Never break wasm** (`cargo xtask wasm`). ## 5. Quality gates (before every commit) diff --git a/Cargo.lock b/Cargo.lock index e2bc7965..9885eb33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,15 +892,17 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "deckcraft" -version = "0.7.0" +version = "0.10.0" dependencies = [ "arboard", + "chrono", "cpal", "deckcraft-engine", "deckcraft-media", "deckcraft-ui-egui", "eframe", "egui", + "fmv-macos-events", "image", "log", "rfd", @@ -911,7 +913,7 @@ dependencies = [ [[package]] name = "deckcraft-anim" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-geom", "deckcraft-model", @@ -920,7 +922,7 @@ dependencies = [ [[package]] name = "deckcraft-av1" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -929,31 +931,33 @@ dependencies = [ [[package]] name = "deckcraft-bitstream" -version = "0.7.0" +version = "0.10.0" dependencies = [ "proptest", ] [[package]] name = "deckcraft-cli" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-engine", "deckcraft-mcp", "serde_json", + "zip", ] [[package]] name = "deckcraft-color" -version = "0.7.0" +version = "0.10.0" dependencies = [ "serde", ] [[package]] name = "deckcraft-engine" -version = "0.7.0" +version = "0.10.0" dependencies = [ + "chrono", "deckcraft-anim", "deckcraft-color", "deckcraft-fonts", @@ -977,7 +981,7 @@ dependencies = [ [[package]] name = "deckcraft-fonts" -version = "0.7.0" +version = "0.10.0" dependencies = [ "epaint_default_fonts", "harfrust 0.13.3", @@ -989,7 +993,7 @@ dependencies = [ [[package]] name = "deckcraft-format" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-model", "serde", @@ -1000,7 +1004,7 @@ dependencies = [ [[package]] name = "deckcraft-geom" -version = "0.7.0" +version = "0.10.0" dependencies = [ "kurbo", "serde", @@ -1008,7 +1012,7 @@ dependencies = [ [[package]] name = "deckcraft-h264" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1017,7 +1021,7 @@ dependencies = [ [[package]] name = "deckcraft-hevc" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1026,7 +1030,7 @@ dependencies = [ [[package]] name = "deckcraft-isobmff" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-bitstream", "thiserror 2.0.21", @@ -1034,11 +1038,11 @@ dependencies = [ [[package]] name = "deckcraft-matroska" -version = "0.7.0" +version = "0.10.0" [[package]] name = "deckcraft-mcp" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-engine", "deckcraft-render", @@ -1048,7 +1052,7 @@ dependencies = [ [[package]] name = "deckcraft-media" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-av1", "deckcraft-h264", @@ -1066,7 +1070,7 @@ dependencies = [ [[package]] name = "deckcraft-metafile" -version = "0.7.0" +version = "0.10.0" dependencies = [ "image", "kurbo", @@ -1075,7 +1079,7 @@ dependencies = [ [[package]] name = "deckcraft-model" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-color", "deckcraft-geom", @@ -1087,15 +1091,15 @@ dependencies = [ [[package]] name = "deckcraft-ogg" -version = "0.7.0" +version = "0.10.0" [[package]] name = "deckcraft-opus" -version = "0.7.0" +version = "0.10.0" [[package]] name = "deckcraft-pdf" -version = "0.7.0" +version = "0.10.0" dependencies = [ "craft-pdf", "deckcraft-fonts", @@ -1113,7 +1117,7 @@ dependencies = [ [[package]] name = "deckcraft-pptx" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-color", "deckcraft-engine", @@ -1129,7 +1133,7 @@ dependencies = [ [[package]] name = "deckcraft-render" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1148,7 +1152,7 @@ dependencies = [ [[package]] name = "deckcraft-text" -version = "0.7.0" +version = "0.10.0" dependencies = [ "chrono", "deckcraft-color", @@ -1162,7 +1166,7 @@ dependencies = [ [[package]] name = "deckcraft-ui-egui" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1184,7 +1188,7 @@ dependencies = [ [[package]] name = "deckcraft-vp9" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1193,7 +1197,7 @@ dependencies = [ [[package]] name = "deckcraft-web" -version = "0.7.0" +version = "0.10.0" dependencies = [ "deckcraft-engine", "deckcraft-ui-egui", @@ -1630,6 +1634,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fmv-macos-events" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" +dependencies = [ + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + [[package]] name = "fnv" version = "1.0.7" @@ -5720,7 +5735,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.7.0" +version = "0.10.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index c9cb8182..1b52575f 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.7.0" +version = "0.10.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -49,7 +49,9 @@ rayon = "1" unicode-bidi = "0.3" unicode-segmentation = "1" unicode-linebreak = "0.1" -chrono = { version = "0.4", default-features = false, features = ["clock"] } +# Base features are wasm-safe (`std` only). The non-wasm `clock` feature is requested +# per-crate for targets that read the local clock (see crates/text). +chrono = { version = "0.4", default-features = false, features = ["std"] } # text / rendering / formats skrifa = "0.47" diff --git a/README.md b/README.md index 82903b44..69bda7b1 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,19 @@

+

+ DeckCraft icon: a presentation screen with a bar chart on an orange tile +

+

DeckCraft

- Presentations and slide shows; an open-source, clean-room reimplementation of Microsoft PowerPoint, rebuilt in pure Rust. + Presentations and slide shows; an open-source, modern presentation and slide designer, built in pure Rust.

Build decks with themes, layouts, shapes, pictures, tables, charts, transitions and animations, - open and save PowerPoint files, and present them full screen. Native on macOS, Windows, Linux + open and save .pptx presentation files, and present them full screen. Native on macOS, Windows, Linux and FreeBSD, and in the browser. Every button is also a command that the CLI and AI agents (through MCP) can drive.

@@ -25,6 +29,9 @@ License: MIT OR Apache-2.0 Written in Rust Platforms +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

@@ -74,7 +81,7 @@ Today it can: audio and video (MP3, AAC, FLAC, ALAC, Ogg, Opus, WAV, AIFF; H.264, HEVC, VP9, AV1). - **Present**: transitions (including Morph), entrance/emphasis/exit and motion-path animations, by-paragraph builds, full-screen slide show, presenter view, pen, rehearse timings, media playback. -- **Exchange files**: open and save PowerPoint `.pptx`, export PDF (slides, notes pages, handouts +- **Exchange files**: open and save .pptx presentations, export PDF (slides, notes pages, handouts with selectable text), PNG/JPEG, an animated GIF of the deck and outlines. Unsaved work is kept by AutoRecover. - **Automate**: 200+ commands with undo, all scriptable from the CLI, a JSON control channel and an MCP server for AI agents. @@ -132,7 +139,7 @@ settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/deckcraf `$XDG_CONFIG_HOME/deckcraft/logs/`; macOS `~/Library/Application Support/DeckCraft/logs/`; Windows `%APPDATA%\DeckCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so this file is what to attach to a bug report: the crash guard's panic report, a failed crash-recovery save, -PowerPoint parts skipped on import and audio output problems land there. Each launch moves the +Parts skipped on import and audio output problems land there. Each launch moves the previous log to `deckcraft.1.log` (and that one to `deckcraft.2.log`), so the log of a run that crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file. @@ -158,8 +165,8 @@ deckcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # con ## Roadmap -DeckCraft is in **alpha**. It covers **84% of PowerPoint's features by breadth** (92% of the core -ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated). +DeckCraft is in **alpha**. It covers **84% of the features tracked in [`docs/target-app-parity.md`](docs/target-app-parity.md) by breadth** (92% of the core +ones, measured) and is about **55% ready for real work across all tracked capabilities** (estimated). See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next, [docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment, [docs/gaps.md](docs/gaps.md) for every known shortfall and @@ -279,15 +286,13 @@ Bundled fonts, icons, images and other assets keep their own open licenses; each with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). DeckCraft's themes, layouts, shape geometry, icons and sample decks are original work, drawn or -generated in code; no Microsoft artwork, fonts, themes or templates are used. +generated in code; no proprietary artwork, fonts, themes or templates are used. The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as part of this repository and DeckCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Microsoft and PowerPoint are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. DeckCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

ArtCraft
Made by the ArtCraft team and community. diff --git a/ROADMAP.md b/ROADMAP.md index 7dfa96f8..4268eec9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -131,7 +131,20 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | +| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) | +| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | +| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape | +| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) | +| 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) | +| 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection | +| 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions | +| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) | +| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute | | 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) | +| 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) | +| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off | +| 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) | | 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) | | 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) | | 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. | @@ -195,6 +208,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read | | 2026-10-13 | Saving a presentation keeps the embedded workbook behind an unchanged chart, and the chart part's relationships (#335). Editing the chart drops that workbook instead of writing numbers that no longer match it | | 2026-10-13 | A presentation whose object ids are used up refuses to grow instead of handing out duplicate ids (#407) | +| 2026-10-13 | macOS: a Finder double-click, Open With, a drop on the Dock icon and `open -a DeckCraft deck.pptx` open the file (#138, #347). `Info.plist` now declares the documents DeckCraft opens (`.deckcraft`, `.pptx`, `.potx`, `.ppsx`, the same set as the Windows installer and the Linux desktop file, kept equal by a test), and the `kAEOpenDocuments` Apple event is handled through the pinned `fmv-macos-events` crate as in GridCraft; the `-psn_` argument older macOS adds on a Finder launch is ignored. PowerPoint stays the default until the user picks DeckCraft | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | @@ -227,6 +241,12 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | +| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged | +| 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) | +| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | +| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) | +| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) | | 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) | | 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing | | 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha | @@ -280,6 +300,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-13 | trivial | Progress log: Grid Options dialog | | 2026-10-14 | trivial | Progress log: pen pressure scales ink width | | 2026-10-13 | trivial | Progress log: chart workbook round-trip (#335) | +| 2026-10-13 | trivial | Progress log: Finder opens presentations on macOS (#138) | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-13 | trivial | Progress log: slide show for interactive decks | | 2026-10-12 | minor | Progress log: table cell editing and border resizing | diff --git a/apps/deckcraft-cli/Cargo.toml b/apps/deckcraft-cli/Cargo.toml index 898a1fb3..6d2f572e 100644 --- a/apps/deckcraft-cli/Cargo.toml +++ b/apps/deckcraft-cli/Cargo.toml @@ -17,3 +17,6 @@ path = "src/main.rs" deckcraft-engine = { workspace = true } deckcraft-mcp = { workspace = true } serde_json = { workspace = true } + +[dev-dependencies] +zip = { workspace = true } diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs index 535ce848..4227905f 100644 --- a/apps/deckcraft-cli/src/help.rs +++ b/apps/deckcraft-cli/src/help.rs @@ -19,15 +19,16 @@ The shapes of one slide, with their ids, boxes and text, come from the slide.ins }, Subcommand { name: "render", - usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT", + usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] [--transparent] OUT", summary: "draw slides as PNG images", details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first; OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per slide. Prints each image's path and size. - --slide N the slide at index N; indices start at 0, and 0 is the default - --scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080 - --edit draw what the editor shows: placeholder prompts and their dashed outlines + --slide N the slide at index N; indices start at 0, and 0 is the default + --scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080 + --edit draw what the editor shows: placeholder prompts and their dashed outlines + --transparent leave the slide background out, so only what is on the slide is drawn deckcraft-cli render deck.deckcraft --slide 2 slide-3.png", }, diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index 5934973e..c6276efb 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -117,11 +117,27 @@ fn open(s: &mut Session, input: Option<&str>, sample: bool) -> Result<(), String return deckcraft_engine::sample::open_sample(s).map_err(|e| e.to_string()); } match input { - Some(p) => s.execute("file.open", &json!({"path": p})).map(|_| ()).map_err(|e| e.to_string()), + Some(p) => { + let opened = s.execute("file.open", &json!({"path": p})).map_err(|e| e.to_string())?; + warn_if_damaged(p, &opened); + Ok(()) + } None => s.execute("file.new", &json!({})).map(|_| ()).map_err(|e| e.to_string()), } } +/// Say on stderr what a file lost on opening, so a saved copy doesn't silently miss it (#203). +fn warn_if_damaged(path: &str, opened: &serde_json::Value) { + let count = |key: &str| opened.get(key).and_then(|v| v.as_array()).map_or(0, |a| a.len()); + let (slides, media) = (count("lostSlides"), count("damagedMedia")); + if slides > 0 { + eprintln!("deckcraft-cli: warning: {path}: {slides} slide{} couldn't be read and left out", if slides == 1 { "" } else { "s" }); + } + if media > 0 { + eprintln!("deckcraft-cli: warning: {path}: {media} picture or media file{} couldn't be read", if media == 1 { "" } else { "s" }); + } +} + /// Median and slowest of `runs` timings of `f`, in milliseconds. fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f64, f64), String> { let mut ms = Vec::with_capacity(runs); @@ -244,11 +260,13 @@ fn render(args: &[String]) -> Result<(), String> { let mut all = false; let mut scale = 2.0; let mut edit = false; + let mut transparent = false; let mut out = None; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--sample" => sample = true, + "--transparent" => transparent = true, "--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?), "--all" => all = true, "--edit" => edit = true, @@ -281,7 +299,7 @@ fn render(args: &[String]) -> Result<(), String> { std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?; } for i in list { - let (png, w, h) = deckcraft_engine::cmd::file::render_png(&st.doc, i, scale, edit); + let (png, w, h) = deckcraft_engine::cmd::file::render_png_with(&st.doc, i, scale, edit, transparent); let path = if all { format!("{out}/slide-{:02}.png", i + 1) } else { out.clone() }; std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?; outln!("{path} ({w}×{h})"); @@ -680,6 +698,21 @@ mod tests { } } + /// `render --transparent` leaves the slide background out (#784): the corner is transparent. + #[test] + fn render_transparent_leaves_the_background_out() { + let dir = std::env::temp_dir().join(format!("deckcraft-cli-transparent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let alpha = |path: &std::path::Path| deckcraft_engine::render::decode_image(&std::fs::read(path).unwrap()).unwrap().get_pixel(1, 1).0[3]; + let opaque = dir.join("opaque.png"); + render(&args(&["--sample", "--scale", "0.25", &opaque.to_string_lossy()])).unwrap(); + assert_eq!(alpha(&opaque), 255); + let clear = dir.join("clear.png"); + render(&args(&["--sample", "--transparent", "--scale", "0.25", &clear.to_string_lossy()])).unwrap(); + assert_eq!(alpha(&clear), 0); + let _ = std::fs::remove_dir_all(&dir); + } + /// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs. #[test] fn exports_an_animated_gif() { diff --git a/apps/deckcraft-cli/tests/damaged.rs b/apps/deckcraft-cli/tests/damaged.rs new file mode 100644 index 00000000..de85a66b --- /dev/null +++ b/apps/deckcraft-cli/tests/damaged.rs @@ -0,0 +1,53 @@ +//! Opening a `.pptx` that lost slides says so on stderr instead of saving a shorter deck in silence (#203). + +use std::io::{Cursor, Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn cli(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap() +} + +fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("deckcraft-cli-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// `deck` with `ppt/slides/slide2.xml` cut off. +fn without_slide2(deck: &[u8]) -> Vec { + let mut zin = zip::ZipArchive::new(Cursor::new(deck)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default(); + for i in 0..zin.len() { + let mut f = zin.by_index(i).unwrap(); + let mut data = vec![]; + f.read_to_end(&mut data).unwrap(); + if f.name() == "ppt/slides/slide2.xml" { + data = b" Self { + let (registration, inbox) = Registration::install(); + Self { _registration: registration, inbox } + } + + /// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`. + pub fn connect(&self, ctx: &egui::Context) -> Inbox { + let ctx = ctx.clone(); + self.inbox.set_wake(move || ctx.request_repaint()); + self.inbox.clone() + } +} + +/// Open the documents and act on the quit requests that arrived since the last frame. +pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) { + for e in inbox.drain() { + match e { + Event::Open(paths) => { + for p in paths.iter().filter_map(|p| p.to_str()) { + if let Err(e) = app.open_path(p) { + log::error!("{p}: {e}"); + app.set_status(format!("Couldn't open {p}: {e}")); + } + } + } + Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } +} diff --git a/apps/deckcraft/src/control_server.rs b/apps/deckcraft/src/control_server.rs index 0abac12a..b4b80c1d 100644 --- a/apps/deckcraft/src/control_server.rs +++ b/apps/deckcraft/src/control_server.rs @@ -1,8 +1,11 @@ //! Loopback JSON-lines control server: one request per line, one reply per line. //! This is the transport the MCP server (`deckcraft-cli mcp --connect`) wraps. //! -//! Every resource a client can hold is bounded ([`Limits`]): simultaneous connections, request -//! line length, queued requests, and how long a socket may sit idle or blocked on a write. +//! The server only reads requests: a line that is not a JSON object with a string `method` (an +//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is +//! closed, so nothing sent after it runs. Every resource a client can hold is bounded +//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a +//! socket may sit idle or blocked on a write. use std::io::{BufRead, BufReader, ErrorKind, Read, Write}; use std::net::{TcpListener, TcpStream}; @@ -120,6 +123,53 @@ fn read_line(r: &mut impl BufRead, max: usize) -> Line { } } +/// One request line, parsed. +enum Parsed { + Request { + id: Value, + method: String, + params: Value, + }, + Blank, + /// Not a request: reply with this error and close the connection. + Reject(String), +} + +fn parse(bytes: &[u8]) -> Parsed { + let Ok(text) = std::str::from_utf8(bytes) else { + return Parsed::Reject("not a control request (invalid UTF-8); closing the connection".into()); + }; + let text = text.trim(); + if text.is_empty() { + return Parsed::Blank; + } + if is_http_request_line(text) { + return Parsed::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into()); + } + let msg = match serde_json::from_str::(text) { + Ok(v) => v, + Err(e) => return Parsed::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")), + }; + let Some(obj) = msg.as_object() else { + return Parsed::Reject("not a control request (expected a JSON object); closing the connection".into()); + }; + let Some(method) = obj.get("method").and_then(Value::as_str) else { + return Parsed::Reject("not a control request (missing string `method`); closing the connection".into()); + }; + Parsed::Request { + id: obj.get("id").cloned().unwrap_or(Value::Null), + method: method.to_string(), + params: obj.get("params").cloned().unwrap_or(json!({})), + } +} + +/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`). +fn is_http_request_line(text: &str) -> bool { + const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"]; + text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method)) + || text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/")) +} + fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context, limits: Limits) { if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { return; @@ -136,20 +186,14 @@ fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context } Line::Closed => break, }; - let Ok(line) = String::from_utf8(line) else { - if writeln!(out, "{}", json!({"ok": false, "error": "bad JSON: not UTF-8"})).is_err() { + let reply = match parse(&line) { + Parsed::Blank => continue, + Parsed::Reject(e) => { + // The connection closes either way; a peer that can't read the reason loses nothing. + let _ = writeln!(out, "{}", json!({"ok": false, "error": e})); break; } - continue; - }; - if line.trim().is_empty() { - continue; - } - let reply = match serde_json::from_str::(&line) { - Ok(msg) => { - let id = msg.get("id").cloned().unwrap_or(Value::Null); - let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); - let params = msg.get("params").cloned().unwrap_or(json!({})); + Parsed::Request { id, method, params } => { let (req, rrx) = ControlRequest::new(method, params); let state = Arc::clone(&req.state); let mut r = match tx.try_send(req) { @@ -177,7 +221,6 @@ fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context } r } - Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}), }; if let Err(e) = writeln!(out, "{reply}") { if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) { @@ -245,6 +288,45 @@ mod tests { assert_eq!(v["result"], "ui.inspect"); } + /// Every reply line until the server closes the connection. + fn replies_until_closed(r: &mut BufReader) -> Vec { + std::iter::from_fn(|| reply(r)).collect() + } + + /// A browser can POST to the port: its request line closes the connection before the JSON + /// line in its body is read, so nothing is dispatched. + #[test] + fn an_http_request_closes_the_connection_before_its_body_is_dispatched() { + let (addr, rx) = server(Limits { max_line: 512, ..small() }); + let (mut r, mut w) = client(addr); + w.write_all( + b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\ + {\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n", + ) + .unwrap(); + let replies = replies_until_closed(&mut r); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}"); + assert!(rx.try_recv().is_err(), "the body was dispatched"); + } + + #[test] + fn a_line_that_is_not_a_request_closes_the_connection() { + let (addr, rx) = server(small()); + ui(rx); + for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] { + let (mut r, mut w) = client(addr); + w.write_all(junk).unwrap(); + w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap(); + let replies = replies_until_closed(&mut r); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert_eq!(replies[0]["ok"], false); + } + assert!(is_http_request_line("OPTIONS * HTTP/1.1")); + assert!(is_http_request_line("PRI * HTTP/2.0")); + assert!(!is_http_request_line("{\"method\":\"GET /\"}")); + } + #[test] fn oversized_line_closes_connection() { let (addr, rx) = server(small()); diff --git a/apps/deckcraft/src/logging.rs b/apps/deckcraft/src/logging.rs index a0ca5048..f2cbae0d 100644 --- a/apps/deckcraft/src/logging.rs +++ b/apps/deckcraft/src/logging.rs @@ -95,29 +95,35 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock +/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved) +/// instead of panicking. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd -/// clock can't overflow). -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years +/// (146_097 days), so month, day and time of day come from chrono and only the year is carried, +/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is +/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`. +fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -389,6 +395,17 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than + // collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built + // with a checked add and only asserted where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index ef80daaa..e6bd6c47 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -1,6 +1,7 @@ //! DeckCraft desktop app. //! //! Usage: `deckcraft [--control ] [--sample] [--show] [files…]` +//! A PowerPoint Show (`.ppsx`) given last starts its slide show, as a double-click does in PowerPoint. //! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits. //! //! `--control ` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: @@ -13,6 +14,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[cfg(target_os = "macos")] +mod apple_events; mod audio; mod control_server; #[cfg(all(unix, not(target_os = "macos")))] @@ -26,10 +29,17 @@ mod pasteboard; use deckcraft_engine::Session; use deckcraft_ui_egui::{Services, SlideApp}; -struct App(SlideApp); +struct App( + SlideApp, + /// Files opened from Finder and quit requests (see `apple_events`). + #[cfg(target_os = "macos")] + fmv_macos_events::Inbox, +); impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + #[cfg(target_os = "macos")] + apple_events::poll(&self.1, &mut self.0, ctx); self.0.logic(ctx); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { @@ -134,6 +144,8 @@ Usage: deckcraft [--control PORT] [--sample] [--show] [FILE...] --show start the slide show --help, -h print this help and exit --version, -V print the version and exit + +A PowerPoint Show (.ppsx) given last starts its slide show. "; enum Startup { @@ -155,6 +167,8 @@ fn startup(mut control_port: Option, args: impl IntoIterator "--show" => show = true, "--version" | "-V" => return Startup::Version, "--help" | "-h" => return Startup::Help, + // The process serial number older macOS adds when Finder launches the app. + a if a.starts_with("-psn_") => {} _ => files.push(a), } } @@ -231,6 +245,12 @@ fn main() -> eframe::Result { // Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs). #[cfg(target_os = "windows")] graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env()); + // Registered before the event loop starts, so it catches the Finder event that launched us as + // well as later ones. Lives until the event loop returns; the app creator only borrows it. + #[cfg(target_os = "macos")] + let apple_events = apple_events::AppleEvents::install(); + #[cfg(target_os = "macos")] + let apple_events = &apple_events; eframe::run_native( "DeckCraft", options, @@ -254,15 +274,18 @@ fn main() -> eframe::Result { if sample && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) { log::error!("sample: {e}"); } - for f in files { - if let Err(e) = app.open_path(&f) { - log::error!("{f}: {e}"); - } + // A .ppsx opened last starts its slide show (#862). + for (f, e) in app.open_from_system(&files) { + log::error!("{f}: {e}"); } if show { app.start_show(0, false); } - Ok(Box::new(App(app))) + Ok(Box::new(App( + app, + #[cfg(target_os = "macos")] + apple_events.connect(&cc.egui_ctx), + ))) }), ) } @@ -276,7 +299,11 @@ mod tests { #[test] fn on_exit_ends_the_process_on_macos() { if std::env::var_os("DECKCRAFT_TEST_ON_EXIT").is_some() { - let mut app = App(SlideApp::new(Session::new(), Services::default())); + let mut app = App( + SlideApp::new(Session::new(), Services::default()), + #[cfg(target_os = "macos")] + fmv_macos_events::Inbox::default(), + ); println!("calling on_exit"); eframe::App::on_exit(&mut app); println!("on_exit returned"); @@ -309,4 +336,86 @@ mod tests { assert!(!show); } } + + #[test] + fn finder_process_serial_number_is_not_a_file() { + let launch = startup(None, ["-psn_0_1234567".to_string(), "talk.pptx".to_string()]); + assert!(matches!(launch, Startup::Run { .. })); + if let Startup::Run { files, .. } = launch { + assert_eq!(files, ["talk.pptx".to_string()]); + } + } + + /// The `` values of every `` that follows a `{key}` line. + fn plist_strings(plist: &str, key: &str) -> Vec { + let mut out = Vec::new(); + let mut in_array = false; + for line in plist.lines() { + let line = line.trim(); + if line == format!("{key}") { + in_array = true; + continue; + } + if in_array { + for part in line.split("").skip(1) { + if let Some((s, _)) = part.split_once("") { + out.push(s.to_string()); + } + } + if line.ends_with("") { + in_array = false; + } + } + } + out + } + + /// Every desktop offers the same file types: macOS (`CFBundleTypeExtensions` and the native + /// type's extension in `Info.plist.in`), Windows (`SupportedTypes` in `deckcraft.wxs`) and + /// Linux (`MimeType` in the desktop file). A format added to one installer must reach the + /// others, and each must be one Finder, Explorer or the file manager can hand to DeckCraft. + #[test] + fn every_desktop_offers_the_same_file_types() { + let plist = include_str!("../../../packaging/macos/Info.plist.in"); + let wxs = include_str!("../../../packaging/windows/deckcraft.wxs"); + let desktop = include_str!("../../../packaging/linux/ai.storyteller.deckcraft.desktop"); + + let mut mac = plist_strings(plist, "CFBundleTypeExtensions"); + // The native type is declared by its UTI; its extension is in the exported type. + mac.extend(plist_strings(plist, "public.filename-extension").into_iter().filter(|e| e == "deckcraft")); + mac.sort(); + mac.dedup(); + assert!(mac.iter().any(|e| e == "pptx") && mac.iter().any(|e| e == "deckcraft"), "Info.plist.in: {mac:?}"); + // Every document type names a content type too, so "Change All" in Finder's Get Info works. + let names = plist.matches("CFBundleTypeName").count(); + let content_types = plist.matches("LSItemContentTypes").count(); + assert!(names > 0 && names == content_types, "{names} document types, {content_types} with LSItemContentTypes"); + + let mut win: Vec = wxs + .lines() + .filter(|l| l.contains("SupportedTypes\" Name=\".")) + .filter_map(|l| l.split("Name=\".").nth(1)?.split('"').next().map(str::to_string)) + .collect(); + win.sort(); + + let mime_ext = |m: &str| match m { + "application/x-deckcraft" => Some("deckcraft"), + "application/vnd.openxmlformats-officedocument.presentationml.presentation" => Some("pptx"), + "application/vnd.openxmlformats-officedocument.presentationml.template" => Some("potx"), + "application/vnd.openxmlformats-officedocument.presentationml.slideshow" => Some("ppsx"), + _ => None, + }; + let mut linux: Vec = desktop + .lines() + .find_map(|l| l.strip_prefix("MimeType=")) + .unwrap_or_default() + .split(';') + .filter(|m| !m.is_empty()) + .map(|m| mime_ext(m).unwrap_or(m).to_string()) // an unknown MIME type shows up in the diff + .collect(); + linux.sort(); + + assert_eq!(mac, win, "macOS and Windows offer different file types"); + assert_eq!(mac, linux, "macOS and Linux offer different file types"); + } } diff --git a/contributors/people.toml b/contributors/people.toml index f70303a1..14b0e935 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -54,3 +54,8 @@ real_name = "Nikita Rakitin" [people.RCShoemaker] real_name = "Ryan C. Shoemaker" + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false diff --git a/crates/anim/src/show.rs b/crates/anim/src/show.rs index 3d9c241f..a1117c62 100644 --- a/crates/anim/src/show.rs +++ b/crates/anim/src/show.rs @@ -299,7 +299,8 @@ impl ShowState { ShowAction::FinishStep(n) } - /// ← / Backspace: undo the last step, else go to the previous slide fully built. + /// ← / Backspace: undo the last step, else go to the previous slide fully built. An automatic + /// first step (With/After Previous) is part of the slide appearing, so it is never undone. pub fn prev(&mut self, pres: &Presentation) -> ShowAction { if self.ended { self.ended = false; @@ -310,13 +311,16 @@ impl ShowState { self.playing = false; return ShowAction::Slide(self.slide); } - if self.playing { + // #844: undoing the automatic first step left the slide as before it appeared, and the show + // played the step again at once, so Back never got past the slide. + let undo = if self.playing { Some(self.step) } else { self.step.checked_sub(1) }; + let auto_first = undo == Some(0) && !pres.show.without_animation && ShowState::timeline(pres, self.slide).step_is_auto(0); + if let Some(k) = undo + && !auto_first + { self.playing = false; - return ShowAction::StepBack(self.step); - } - if self.step > 0 { - self.step -= 1; - return ShowAction::StepBack(self.step); + self.step = k; + return ShowAction::StepBack(k); } let target = match self.prev_index(pres) { Some(i) => Some(i), diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 1ac69104..da158ae9 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -808,6 +808,30 @@ fn show_steps_through_builds() { assert_eq!(s.step, 1); } +#[test] +fn show_back_leaves_a_slide_whose_first_step_is_automatic() { + // #844: undoing the automatic first step left the slide as before it appeared, and the show + // played that step again at once, so Back never reached the slide before. + for start in [AnimStart::WithPrevious, AnimStart::AfterPrevious] { + let mut p = pres(2); + Arc::make_mut(&mut p.slides[1]).shapes = vec![shape(1, "A"), shape(2, "B")]; + Arc::make_mut(&mut p.slides[1]).animations = + vec![anim(1, AnimClass::Entrance, "fade", start), anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick)]; + let mut s = ShowState::new(&p); + assert_eq!(s.next(&p), ShowAction::Slide(1)); + assert!(s.playing, "{start:?}"); + // While the automatic step plays. + assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}"); + assert_eq!(s.next(&p), ShowAction::Slide(1)); + // Once it has played, the click step is undone first, then Back leaves the slide. + assert_eq!(s.next(&p), ShowAction::PlayStep(1)); + assert_eq!(s.step_done(&p), ShowAction::FinishStep(1)); + assert_eq!(s.prev(&p), ShowAction::StepBack(1), "{start:?}"); + assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}"); + assert_eq!((s.step, s.playing), (0, false)); + } +} + #[test] fn show_auto_advance() { let mut p = pres(2); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 778664a7..cb1f4c29 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -25,6 +25,7 @@ i_overlay = { workspace = true } thiserror = { workspace = true } log = { workspace = true } image = { workspace = true } +chrono = { workspace = true } [dev-dependencies] deckcraft-anim = { workspace = true } diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index c3e27804..1a97a3b3 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -525,4 +525,46 @@ mod tests { s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap(); assert!(s.anim_painter.is_some()); } + + fn setup_triggers() -> (Session, [u64; 4]) { + let mut s = Session::with_new(); + let mut ids = [0u64; 4]; + for (slot, x) in ids.iter_mut().zip([10.0, 150.0, 300.0, 450.0]) { + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10.0, 100.0, 100.0]})).unwrap(); + *slot = r.get("id").unwrap().as_u64().unwrap(); + } + (s, ids) + } + + fn triggers(s: &Session) -> Vec> { + s.doc().unwrap().current_slide().unwrap().animations.iter().map(|x| x.trigger).collect() + } + + #[test] + fn deleting_a_trigger_shape_clears_the_trigger() { + let (mut s, [a, b, c, d]) = setup_triggers(); + let id = |v: u64| Some(deckcraft_model::ShapeId(v as u32)); + s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap(); + s.execute("animation.add", &json!({"effect": "fade", "ids": [d]})).unwrap(); + s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap(); + s.execute("animation.timing", &json!({"index": 1, "trigger": c})).unwrap(); + assert_eq!(triggers(&s), vec![id(b), id(c)]); + + s.execute("edit.delete", &json!({"ids": [b]})).unwrap(); + assert_eq!(triggers(&s), vec![None, id(c)], "only the deleted trigger is cleared"); + assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 2, "the animated shapes keep their effects"); + } + + #[test] + fn deleting_a_group_clears_triggers_of_its_children() { + let (mut s, [a, b, c, _]) = setup_triggers(); + let g = s.execute("arrange.group", &json!({"ids": [b, c]})).unwrap().get("id").unwrap().as_u64().unwrap(); + s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap(); + s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap(); + assert_eq!(triggers(&s), vec![Some(deckcraft_model::ShapeId(b as u32))]); + + s.execute("edit.delete", &json!({"ids": [g]})).unwrap(); + assert_eq!(triggers(&s), vec![None], "the trigger was inside the deleted group"); + assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 1); + } } diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 03468f37..012e33ed 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -943,18 +943,21 @@ pub fn date_field_text(kind: &str) -> String { DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default() } -/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm). +/// Days since 1970-01-01 → (year, month, day), exact for every `i64`. pub fn civil(z: i64) -> (i64, u32, u32) { - let z = z + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - (if m <= 2 { y + 1 } else { y }, m, d) + use chrono::Datelike; + // `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400 + // years (146_097 days). chrono converts the day within its cycle and whole cycles go back + // into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and + // 0..146_097 days from it always fits. + let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097)); + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + None => (1970, 1, 1), + } } fn master_view(s: &mut Session, p: &Value) -> Result { @@ -1495,6 +1498,11 @@ mod tests { fn civil_dates() { assert_eq!(super::civil(0), (1970, 1, 1)); assert_eq!(super::civil(20_734), (2026, 10, 8)); + // Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes. + assert_eq!(super::civil(95_026_602), (262_144, 1, 1)); + assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1)); + assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27)); + assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7)); } #[test] diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3b4346e1..cb552d65 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -175,7 +175,11 @@ fn copied_html(s: &Session) -> String { fn cut(s: &mut Session, p: &Value) -> Result { let caret = copying_caret(s); let t = copy_inner(s, p)?; - let html = if caret { String::new() } else { copied_html(s) }; + if caret { + // Nothing is selected, so there is nothing to cut; `delete` would remove the next character. + return Ok(json!({"text": t, "html": ""})); + } + let html = copied_html(s); delete(s, p)?; Ok(json!({"text": t, "html": html})) } @@ -568,12 +572,24 @@ fn delete(s: &mut Session, p: &Value) -> Result { } s.edit(|doc, sel| { let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.delete", "no slide"))?; + let mut before = Vec::new(); + deckcraft_model::walk(shapes, &mut |s, _| before.push(s.id)); let n = remove_shapes(shapes, &ids); + // Everything that is gone: the given ids plus group descendants and emptied groups. + let mut left = Vec::new(); + deckcraft_model::walk(shapes, &mut |s, _| left.push(s.id)); + let mut gone = ids.clone(); + gone.extend(before.into_iter().filter(|i| !left.contains(i))); // Animations of deleted shapes go too. if sel.target == crate::Target::Slides && let Some(sl) = doc.slides.get_mut(sel.slide) { - Arc::make_mut(sl).animations.retain(|a| !ids.contains(&a.shape)); + let sl = Arc::make_mut(sl); + sl.animations.retain(|a| !gone.contains(&a.shape)); + // Effects triggered by a deleted shape fall back to the main sequence. + for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| gone.contains(&t))) { + a.trigger = None; + } } sel.shapes.retain(|i| !ids.contains(i)); sel.text = None; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4761dc10..ba567571 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -33,7 +33,7 @@ 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|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export), + cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export), cmd!( query "file.info", "Info", @@ -43,7 +43,7 @@ pub fn specs() -> Vec { has_doc, info ), - cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render), + cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool, transparentBackground?: bool} → {png: base64, width, height}", has_doc, render), cmd!( query "slide.colorAt", "Color At", @@ -128,19 +128,35 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result { open_checked(name, bytes).map(|(p, _)| p) } -/// [`open_presentation`], plus the embedded media a `.deckcraft` file had lost or damaged. -pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec)> { - if deckcraft_format::sniff(bytes) { - return deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string())); - } - open_other(name, bytes).map(|p| (p, vec![])) +/// What a file lost on opening. +#[derive(Debug, Default, Clone)] +pub struct Damage { + /// Pictures and media a `.deckcraft` file had lost or damaged. + pub media: Vec, + /// Slides a `.pptx` lists but that couldn't be read, so they are not in the presentation. + pub slides: Vec, } -fn open_other(name: &str, bytes: &[u8]) -> Result { +impl Damage { + pub fn is_empty(&self) -> bool { + self.media.is_empty() && self.slides.is_empty() + } +} + +/// [`open_presentation`], plus what the file had lost or damaged. +pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Damage)> { + if deckcraft_format::sniff(bytes) { + let (p, media) = deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?; + return Ok((p, Damage { media, slides: vec![] })); + } + open_other(name, bytes).map(|(p, slides)| (p, Damage { media: vec![], slides })) +} + +fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec)> { if deckcraft_pptx::sniff(bytes) { - let mut p = deckcraft_pptx::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?; + let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?; deckcraft_format::repair(&mut p); - return Ok(p); + return Ok((p, lost_slides)); } if deckcraft_pptx::is_encrypted(bytes) { return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet."))); @@ -157,7 +173,7 @@ fn open_other(name: &str, bytes: &[u8]) -> Result { if p.slides.is_empty() { p = defaults::new_presentation(None); } - return Ok(p); + return Ok((p, vec![])); } Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read"))) } @@ -180,8 +196,8 @@ pub struct LoadedFile { pub doc: Presentation, /// A native or PPTX file (saving goes back to it); an outline import has no file to save to. pub native: bool, - /// Pictures and media a `.deckcraft` file had lost or damaged. - pub damaged: Vec, + /// What the file had lost or damaged. + pub damaged: Damage, } /// Read and parse the presentation at `path`. @@ -202,7 +218,10 @@ fn adopt_loaded(s: &mut Session, f: LoadedFile) -> Result { let i = add_opened(s, &path, keep_path.then(|| path.clone()), doc); report_damage(s, &damaged)?; let st = s.doc()?; - Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts, "damagedMedia": damaged})) + Ok(json!({ + "document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts, + "damagedMedia": damaged.media, "lostSlides": damaged.slides, + })) } #[cfg(not(target_arch = "wasm32"))] @@ -236,23 +255,35 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result { let (doc, damaged) = open_checked(name, &data)?; let i = add_opened(s, name, None, doc); report_damage(s, &damaged)?; - Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged})) + Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged.media, "lostSlides": damaged.slides})) } -/// Tell the user which pictures and media a damaged file lost, and mark the copy repaired. -fn report_damage(s: &mut Session, damaged: &[String]) -> Result<()> { +/// Tell the user which pictures, media and slides a damaged file lost, and mark the copy repaired. +fn report_damage(s: &mut Session, damaged: &Damage) -> Result<()> { if damaged.is_empty() { return Ok(()); } let st = s.doc_mut()?; st.title = format!("{} (Repaired)", st.title); - let n = damaged.len(); - let text = format!( - "{n} picture{} or media file{} in this presentation couldn't be read and {} empty. It was opened as a repaired copy; save it under a new name to keep the original file.", - if n == 1 { "" } else { "s" }, - if n == 1 { "" } else { "s" }, - if n == 1 { "is" } else { "are" }, - ); + let mut text = String::new(); + let n = damaged.media.len(); + if n > 0 { + text = format!( + "{n} picture{} or media file{} in this presentation couldn't be read and {} empty. ", + if n == 1 { "" } else { "s" }, + if n == 1 { "" } else { "s" }, + if n == 1 { "is" } else { "are" }, + ); + } + let n = damaged.slides.len(); + if n > 0 { + text.push_str(&format!( + "{n} slide{} in this file couldn't be read and {} left out. ", + if n == 1 { "" } else { "s" }, + if n == 1 { "was" } else { "were" }, + )); + } + text.push_str("It was opened as a repaired copy; save it under a new name to keep the original file."); s.ui_requests.push(crate::UiRequest::Message { text }); Ok(()) } @@ -431,8 +462,14 @@ pub fn image_scale(doc: &Presentation, scale: f64) -> f64 { /// Render slide `index` (current when absent) to PNG bytes. pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec, u32, u32) { + render_png_with(doc, index, scale, edit, false) +} + +/// [`render_png`]; `transparent` leaves the slide background out (#784). +pub fn render_png_with(doc: &Presentation, index: usize, scale: f64, edit: bool, transparent: bool) -> (Vec, u32, u32) { let scale = image_scale(doc, scale); - let img = deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale, edit, ..Default::default() }); + let opts = deckcraft_render::RenderOpts { scale, edit, transparent_background: transparent, ..Default::default() }; + let img = deckcraft_render::render_slide(doc, index, &opts); (img.to_png_at(deckcraft_render::dpi_for_scale(scale)), img.width, img.height) } @@ -451,7 +488,7 @@ fn render(s: &mut Session, p: &Value) -> Result { if i >= st.doc.slides.len() { return Err(bad("file.render", format!("no slide {i}"))); } - let (png, w, h) = render_png(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false)); + let (png, w, h) = render_png_with(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false), bool_or(p, "transparentBackground", false)); Ok(json!({"png": base64_encode(&png), "width": w, "height": h})) } @@ -483,6 +520,10 @@ fn export(s: &mut Session, p: &Value) -> Result { let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string(); let st = s.doc()?; let scale = finite_scale(f64_or(p, "scale", 2.0)); + let transparent = bool_or(p, "transparentBackground", false); + if transparent && format != "png" { + return Err(bad("file.export", format!("a transparent background is for PNG export, not {format}"))); + } match format.as_str() { "png" | "jpeg" | "jpg" => { let slides: Vec = match usize_param(p, "slide") { @@ -497,7 +538,11 @@ fn export(s: &mut Session, p: &Value) -> Result { let scale = image_scale(&st.doc, scale); let mut written = vec![]; for (k, i) in slides.iter().enumerate() { - let img = deckcraft_render::render_slide(&st.doc, *i, &deckcraft_render::RenderOpts { scale, ..Default::default() }); + let img = deckcraft_render::render_slide( + &st.doc, + *i, + &deckcraft_render::RenderOpts { scale, transparent_background: transparent, ..Default::default() }, + ); let bytes = picture_bytes(&img, &format, scale); let out = if slides.len() == 1 { path.clone() diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 21b5474a..b8efeded 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -699,21 +699,28 @@ fn case(s: &mut Session, p: &Value) -> Result { let from = if pi == a.0 { a.1 } else { 0 }; let to = if pi == b.0 { b.1 } else { n }; let mut pos = 0; - for r in &mut para.runs { + let mut pieces = Vec::new(); + for (ri, r) in para.runs.iter().enumerate() { + // Breaks and fields are read for context (a break ends a word) but not rewritten. let len = r.char_len(); - if r.kind == deckcraft_model::text::RunKind::Text { - let rs = from.max(pos).saturating_sub(pos); - let re = to.min(pos + len).saturating_sub(pos); - if re > rs { - let chars: Vec = r.text.chars().collect(); - let mid: String = chars.get(rs..re).map(|c| c.iter().collect()).unwrap_or_default(); - let pre: String = chars.get(..rs).map(|c| c.iter().collect()).unwrap_or_default(); - let post: String = chars.get(re..).map(|c| c.iter().collect()).unwrap_or_default(); - r.text = format!("{pre}{}{post}", ed::change_case(&mid, &m)); - } + let rs = from.max(pos).saturating_sub(pos); + let re = to.min(pos + len).saturating_sub(pos); + if re > rs { + pieces.push((ri, rs, re)); } pos += len; } + let mids: Vec = pieces + .iter() + .map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default()) + .collect(); + let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::>(), &m); + for (&(ri, rs, re), new) in pieces.iter().zip(changed) { + let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue }; + let pre: String = r.text.chars().take(rs).collect(); + let post: String = r.text.chars().skip(re).collect(); + r.text = format!("{pre}{new}{post}"); + } } if collapsed { t.anchor = a; @@ -735,9 +742,12 @@ fn case(s: &mut Session, p: &Value) -> Result { edit_shapes(s, &json!({}), "format.case", |sh| { if let Some(t) = sh.text.as_mut() { for para in &mut t.paragraphs { - for r in &mut para.runs { + // Breaks and fields are read for context (a break ends a word) but not rewritten. + let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect(); + let changed = ed::change_case_parts(&texts, &mode); + for (r, new) in para.runs.iter_mut().zip(changed) { if r.kind == deckcraft_model::text::RunKind::Text { - r.text = ed::change_case(&r.text, &mode); + r.text = new; } } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5695cc24..fa3f6459 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -318,6 +318,14 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { pub(crate) fn usize_param(p: &Value, key: &str) -> Option { p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()) } +/// An index param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when +/// present but not a valid index (negative, non-integer), so a bad index never acts on the selection. +pub(crate) fn opt_usize_param(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` is not a valid index: {v}"))), + } +} /// A shape id param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when /// present but not a valid id (negative, non-integer, beyond `u32`), so a bad id never edits the selection. pub(crate) fn id_param(p: &Value, key: &str, cmd: &str) -> Result> { diff --git a/crates/engine/src/cmd/points.rs b/crates/engine/src/cmd/points.rs index 38fd22b9..3af2c65f 100644 --- a/crates/engine/src/cmd/points.rs +++ b/crates/engine/src/cmd/points.rs @@ -16,7 +16,7 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ cmd!( - noundo "shape.editPoints", + "shape.editPoints", "Edit Points", ["Shape Format", "Insert Shapes", "Edit Shape"], None, diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs index 905f425d..1e6c09ac 100644 --- a/crates/engine/src/cmd/reuse.rs +++ b/crates/engine/src/cmd/reuse.rs @@ -3,11 +3,13 @@ //! The slides take the destination's look by default (each one moves to the destination layout //! with the same name, else the same kind); with `keepFormatting` the source master and its //! layouts come along. Pictures, media and sounds are copied under fresh ids, and shape ids are -//! renumbered with animations, triggers and connector ends following them. +//! renumbered with animations, triggers and connector ends following them. Links to another +//! reused slide follow its copy. use std::collections::HashMap; use std::sync::Arc; +use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody}; use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId}; use serde_json::{Value, json}; @@ -60,6 +62,57 @@ fn remap_connectors(shapes: &mut [deckcraft_model::Shape], map: &HashMap, map: &HashMap) { + let Some(h) = h else { return }; + if let Action::Slide { slide } = &mut h.action { + *slide = map.get(slide).copied().unwrap_or(*slide); + } + if let Some(z) = &mut h.zoom_return { + *z = map.get(z).copied().unwrap_or(*z); + } +} + +fn relink_run(r: &mut RunProps, map: &HashMap) { + relink(&mut r.link, map); + relink(&mut r.hover, map); +} + +fn relink_text(t: &mut TextBody, map: &HashMap) { + for lv in t.list_style.levels.iter_mut().flatten() { + relink_run(&mut lv.run, map); + } + for p in &mut t.paragraphs { + relink_run(&mut p.def_props, map); + relink_run(&mut p.end_props, map); + for r in &mut p.runs { + relink_run(&mut r.props, map); + } + } +} + +/// Point the slide links under `shapes` (click and hover actions, text links) at the copies. +fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap, depth: usize) { + if depth > 64 { + return; + } + for sh in shapes { + relink(&mut sh.click, map); + relink(&mut sh.hover, map); + if let Some(t) = &mut sh.text { + relink_text(t, map); + } + if let ShapeKind::Table(t) = &mut sh.kind { + for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + relink_text(&mut c.text, map); + } + } + if let Some(ch) = sh.children_mut() { + relink_shapes(ch, map, depth + 1); + } + } +} + /// Fresh shape ids for `shapes`, and the old → new map. fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result> { let old = shape_ids(shapes); @@ -134,8 +187,10 @@ fn reuse(s: &mut Session, p: &Value) -> Result { doc.masters.push(Arc::new(m)); } } - let mut new_ids = vec![]; - for (k, &i) in picked.iter().enumerate() { + let mut copies: Vec = vec![]; + // Source slide → its copy, so links between reused slides follow (forward ones too). + let mut slide_map: HashMap = HashMap::new(); + for &i in &picked { let Some(from) = src.slides.get(i) else { continue }; copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map)?; let mut sl: Slide = from.as_ref().clone(); @@ -154,6 +209,13 @@ fn reuse(s: &mut Session, p: &Value) -> Result { matching_layout(doc, &lname, kind) } }; + slide_map.insert(from.id, sl.id); + copies.push(sl); + } + let mut new_ids = vec![]; + for (k, mut sl) in copies.into_iter().enumerate() { + relink_shapes(&mut sl.shapes, &slide_map, 0); + relink_text(&mut sl.notes, &slide_map); new_ids.push(sl.id); doc.slides.insert((at + k).min(doc.slides.len()), Arc::new(sl)); } @@ -263,4 +325,76 @@ mod tests { want.sort(); assert_eq!(got, want, "every reference resolves to its own copied bytes"); } + + fn link_to(slide: SlideId) -> Hyperlink { + Hyperlink { + action: Action::Slide { slide }, + tooltip: String::new(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + } + } + + fn linked_text(slide: SlideId) -> TextBody { + let mut text = TextBody::from_text("go"); + if let Some(r) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) { + r.props.link = Some(link_to(slide)); + } + text + } + + /// Links to a slide that is reused too follow the copy; links to a slide left behind stay. + #[test] + fn reused_slides_link_to_each_others_copies() { + let mut src = defaults::new_presentation(None); + let template = src.slides.first().map(|s| s.as_ref().clone()).expect("slide"); + for _ in 0..2 { + let mut sl = template.clone(); + sl.id = SlideId(src.alloc_id().expect("id")); + src.slides.push(Arc::new(sl)); + } + let ids: Vec = src.slides.iter().map(|s| s.id).collect(); + let id = |i: usize| ids.get(i).copied().unwrap_or_default(); + if let Some(sl) = src.slides.first_mut().map(Arc::make_mut) { + sl.shapes = vec![ + Shape { id: ShapeId(9001), click: Some(link_to(id(1))), ..Default::default() }, + Shape { id: ShapeId(9002), text: Some(linked_text(id(1))), ..Default::default() }, + Shape { id: ShapeId(9003), click: Some(link_to(id(2))), ..Default::default() }, + ]; + } + if let Some(sl) = src.slides.get_mut(1).map(Arc::make_mut) { + sl.shapes = vec![Shape { id: ShapeId(9004), click: Some(link_to(id(0))), ..Default::default() }]; + } + let bytes = deckcraft_format::save(&src).expect("save"); + let reopened = crate::cmd::file::open_presentation("src.deckcraft", &bytes).expect("open"); + let left_behind = reopened.slides.get(2).map(|s| s.id).expect("third slide"); + + let mut s = Session::with_new(); + for _ in 0..3 { + s.execute("slide.new", &json!({})).expect("new"); + } + let out = s + .execute("slide.reuse", &json!({"data": super::super::base64_encode(&bytes), "name": "src.deckcraft", "slides": [0, 1]})) + .expect("reuse"); + let new: Vec = serde_json::from_value(out.get("ids").cloned().unwrap_or_default()).expect("ids"); + let doc = &s.doc().expect("doc").doc; + let copy = |i: usize| new.get(i).and_then(|id| doc.slide(*id)).expect("copied slide"); + let click = |sh: Option<&Shape>| sh.and_then(|sh| sh.click.as_ref()).map(|h| h.action.clone()); + let first = copy(0); + let to = |i: usize| Some(Action::Slide { slide: new.get(i).copied().unwrap_or_default() }); + assert_eq!(click(first.shapes.first()), to(1), "shape click follows the copy"); + let run_link = first + .shapes + .get(1) + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .and_then(|r| r.props.link.as_ref()) + .map(|h| h.action.clone()); + assert_eq!(run_link, to(1), "text link follows the copy"); + assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link"); + assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too"); + } } diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 65933bbd..9b061c8b 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -137,7 +137,14 @@ fn reply(s: &mut Session, p: &Value) -> Result { fn delete(s: &mut Session, p: &Value) -> Result { let all = bool_or(p, "all", false); - let i = usize_param(p, "index"); + let i = match p.get("index") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_u64() + .and_then(|v| usize::try_from(v).ok()) + .ok_or_else(|| bad("comment.delete", format!("`index` is not a valid comment index: {v}")))?, + ), + }; s.edit(|doc, sel| { if all { for sl in &mut doc.slides { @@ -793,4 +800,18 @@ mod tests { s.execute("show.setup", &json!({"custom": ""})).unwrap(); assert!(s.active().unwrap().doc.show.custom_show.is_none()); } + + #[test] + fn delete_rejects_an_invalid_index_and_keeps_the_comments() { + let mut s = Session::with_new(); + s.execute("comment.add", &json!({"text": "a"})).unwrap(); + s.execute("comment.add", &json!({"text": "b"})).unwrap(); + for bad_index in [json!(-1), json!("2"), json!(1.5)] { + let r = s.execute("comment.delete", &json!({"index": bad_index})); + assert!(r.is_err(), "index {bad_index} gave {r:?}"); + assert_eq!(s.active().unwrap().doc.slides[0].comments.len(), 2, "index {bad_index}"); + } + s.execute("comment.delete", &json!({})).unwrap(); + assert!(s.active().unwrap().doc.slides[0].comments.is_empty()); + } } diff --git a/crates/engine/src/cmd/richclip.rs b/crates/engine/src/cmd/richclip.rs index f69e55be..fc63314b 100644 --- a/crates/engine/src/cmd/richclip.rs +++ b/crates/engine/src/cmd/richclip.rs @@ -274,20 +274,22 @@ fn decode_text(raw: &str) -> String { break; }; let entity = &rest[1..end]; - rest = &rest[end + 1..]; - match entity { - "amp" => out.push('&'), - "lt" => out.push('<'), - "gt" => out.push('>'), - "quot" => out.push('"'), - "apos" => out.push('\''), - "nbsp" => out.push(' '), - _ => { - let ch = entity.strip_prefix('#').and_then(numeric_char); - if let Some(c) = ch { - out.push(c); - } - } + let decoded = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some(' '), + _ => entity.strip_prefix('#').and_then(numeric_char), + }; + if let Some(c) = decoded { + out.push(c); + rest = &rest[end + 1..]; + } else { + // Not a well-formed entity (e.g. a bare "R&D"): keep the '&' as typed. + out.push('&'); + rest = &rest[1..]; } } if !rest.is_empty() && !rest.starts_with('&') { @@ -350,6 +352,14 @@ mod tests { assert!(last.props.underline.is_some()); } + #[test] + fn bare_ampersand_keeps_text_up_to_next_semicolon() { + let text = |html: &str| html_to_body(html).unwrap().text(); + assert_eq!(text("

R&D costs; up

"), "R&D costs; up"); + assert_eq!(text("

AT&T; Q&A

"), "AT&T; Q&A"); + assert_eq!(text("

A & B & C & D

"), "A & B & C & D"); + } + #[test] fn pages_style_and_escapes() { let html = "

A & B

C

"; diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index e3bf3920..f1a050d1 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -169,13 +169,24 @@ pub fn specs() -> Vec { ] } +/// Keeps a side length in `0..=MAX_PT` so a huge value cannot exhaust memory when rendering. +/// An overflow to infinity lands on the limit; NaN becomes 0. +fn clamp_side(v: f64) -> f64 { + if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_PT) } +} + +/// Keeps a coordinate within `±POS_LIMIT`. +fn clamp_pos(v: f64) -> f64 { + if v.is_nan() { 0.0 } else { v.clamp(-POS_LIMIT, POS_LIMIT) } +} + 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_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); + x.x = clamp_pos(ax.unwrap_or(x.x) + dx.unwrap_or(0.0)); + x.y = clamp_pos(ay.unwrap_or(x.y) + dy.unwrap_or(0.0)); } Ok(()) }) @@ -202,10 +213,12 @@ fn resize(s: &mut Session, p: &Value) -> Result { x.h = h.unwrap_or(x.h); } } + x.w = clamp_side(x.w); + x.h = clamp_side(x.h); // Rotated shapes resize about their centre; others keep the top-left. if x.rot != 0.0 { - x.x = cx - x.w / 2.0; - x.y = cy - x.h / 2.0; + x.x = clamp_pos(cx - x.w / 2.0); + x.y = clamp_pos(cy - x.h / 2.0); } } Ok(()) @@ -215,10 +228,10 @@ fn resize(s: &mut Session, p: &Value) -> Result { fn set_bounds(s: &mut Session, p: &Value) -> Result { 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); - x.w = f64_param(p, "w").unwrap_or(x.w).max(0.0); - x.h = f64_param(p, "h").unwrap_or(x.h).max(0.0); + x.x = clamp_pos(f64_param(p, "x").unwrap_or(x.x)); + x.y = clamp_pos(f64_param(p, "y").unwrap_or(x.y)); + x.w = clamp_side(f64_param(p, "w").unwrap_or(x.w)); + x.h = clamp_side(f64_param(p, "h").unwrap_or(x.h)); } Ok(()) }) @@ -406,6 +419,8 @@ fn flip(s: &mut Session, p: &Value) -> Result { } else { x.flip_h = !x.flip_h; } + // `Xfrm::affine` flips first and rotates second; a true mirror also negates the rotation. + x.rot = (360.0 - x.rot_norm()) % 360.0; } Ok(()) }) diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index b203f083..777c479c 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -179,7 +179,7 @@ fn body_placeholder(shapes: &mut [deckcraft_model::Shape]) -> Option<&mut deckcr fn index_of(s: &Session, p: &Value) -> Result { let st = s.doc()?; - let i = usize_param(p, "index").unwrap_or(st.selection.slide); + let i = opt_usize_param(p, "index", "slide")?.unwrap_or(st.selection.slide); if i >= st.doc.slides.len() { return Err(bad("slide", format!("no slide {i}"))); } @@ -187,7 +187,7 @@ fn index_of(s: &Session, p: &Value) -> Result { } fn duplicate(s: &mut Session, p: &Value) -> Result { - let indices: Vec = match usize_param(p, "index") { + let indices: Vec = match opt_usize_param(p, "index", "slide.duplicate")? { Some(i) => vec![i], None => { let st = s.doc()?; @@ -240,7 +240,7 @@ fn delete(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; if let Some(a) = p.get("ids").and_then(Value::as_array) { a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(SlideId).collect() - } else if let Some(i) = usize_param(p, "index") { + } else if let Some(i) = opt_usize_param(p, "index", "slide.delete")? { st.doc.slides.get(i).map(|x| vec![x.id]).unwrap_or_default() } else if !st.selection.slides.is_empty() { st.selection.slides.clone() @@ -266,7 +266,7 @@ fn delete(s: &mut Session, p: &Value) -> Result { } fn move_slide(s: &mut Session, p: &Value) -> Result { - let from = usize_param(p, "from").unwrap_or(s.doc()?.selection.slide); + let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide); let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?; s.edit(|doc, sel| { if from >= doc.slides.len() { @@ -531,7 +531,7 @@ fn rename(s: &mut Session, p: &Value) -> Result { } fn section_add(s: &mut Session, p: &Value) -> Result { - let at = usize_param(p, "at").unwrap_or(s.doc()?.selection.slide); + let at = opt_usize_param(p, "at", "section.add")?.unwrap_or(s.doc()?.selection.slide); let name = str_param(p, "name").unwrap_or("Untitled Section").to_string(); s.edit(|doc, _| { let at = at.min(doc.slides.len().saturating_sub(1)); @@ -627,6 +627,17 @@ fn from_outline(s: &mut Session, p: &Value) -> Result { for (k, sl) in new.into_iter().enumerate() { doc.slides.insert(at + k, sl); } + // Join the section of the slide before, as slide.new does. + let ids: Vec = doc.slides.iter().skip(at).take(n).map(|x| x.id).collect(); + let prev = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id); + let section = prev.and_then(|p| doc.section_of(p)); + if let Some(sec) = section.and_then(|si| doc.sections.get_mut(si)) { + let pos = prev.and_then(|p| sec.slides.iter().position(|x| *x == p)).map(|k| k + 1).unwrap_or(sec.slides.len()); + let pos = pos.min(sec.slides.len()); + sec.slides.splice(pos..pos, ids); + } else if let Some(first) = doc.sections.first_mut() { + first.slides.splice(0..0, ids); + } Ok(json!({"slides": n})) }) } @@ -637,6 +648,25 @@ mod tests { use crate::Session; + #[test] + fn invalid_index_params_are_rejected_not_defaulted_to_selection() { + let mut s = Session::with_new(); + while s.doc().expect("doc").doc.slides.len() < 3 { + s.execute("slide.new", &json!({})).expect("new slide"); + } + for bad in [json!(-1), json!(1.5)] { + for (cmd, p) in [ + ("slide.delete", json!({"index": bad})), + ("slide.duplicate", json!({"index": bad})), + ("slide.move", json!({"from": bad, "to": 0})), + ("section.add", json!({"at": bad})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}"); + assert_eq!(s.doc().expect("doc").doc.slides.len(), 3, "{cmd} {p}"); + } + } + } + #[test] fn duplicate_keeps_section_order_in_step_with_slides() { let mut s = Session::with_new(); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index c6728740..8ab95549 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -537,7 +537,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result { with_table(s, p, "table.columnWidth", move |t| { // Keeping the table's width, the column to the right gives or takes the difference (dragging // an inner border). The last column has none: the table gets wider or narrower. - let pair = t.cols.get(c).zip(t.cols.get(c + 1)).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite()); + let pair = t.cols.get(c).zip(t.cols.get(c.saturating_add(1))).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite()); match pair { Some(pair) if keep_total => { let min = MIN_COLUMN_WIDTH.min(pair / 2.0); @@ -545,7 +545,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result { if let Some(x) = t.cols.get_mut(c) { *x = w; } - if let Some(x) = t.cols.get_mut(c + 1) { + if let Some(x) = t.cols.get_mut(c.saturating_add(1)) { *x = pair - w; } } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 2fd959c0..2a4b41db 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "AutoCorrect Options", ["Tools"], None, - "{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?: bool, add?: [from, to], remove?: from} → the options and the replacement list", + "{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?: bool, add?: [from, to], remove?: from} → the options and the replacement list", always, autocorrect_options ), @@ -656,7 +656,12 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> { } else { correct_word(&s.prefs, &word, sentence_start, typing) }; - let Some(rep) = rep else { return Ok(()) }; + let Some(rep) = rep else { + if typing && s.prefs.ac_ordinals && is_ordinal(&word) { + return superscript_suffix(s, &t, word_end); + } + return Ok(()); + }; let delta = rep.chars().count() as isize - word.chars().count() as isize; let st = s.doc_mut()?; let mut d = (*st.doc).clone(); @@ -675,6 +680,43 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> { Ok(()) } +/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat +/// superscripts it. +fn is_ordinal(word: &str) -> bool { + let digits = word.trim_end_matches(|c: char| c.is_ascii_lowercase()); + let suffix = word.get(digits.len()..).unwrap_or_default(); + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) || suffix.len() != 2 { + return false; + } + let d = digits.as_bytes(); + let teen = d.len() >= 2 && d.get(d.len() - 2) == Some(&b'1'); + let want = match d.last() { + _ if teen => "th", + Some(b'1') => "st", + Some(b'2') => "nd", + Some(b'3') => "rd", + _ => "th", + }; + suffix == want +} + +/// Superscript the two letters before `end` in the caret's paragraph (an ordinal's suffix). +fn superscript_suffix(s: &mut Session, t: &crate::TextSel, end: usize) -> Result<()> { + let (a, b) = ((t.caret.0, end.saturating_sub(2)), (t.caret.0, end)); + let st = s.doc_mut()?; + if body_of(st, t).is_none_or(|body| ed::props_at(body, b).baseline.unwrap_or(0.0) > 0.0) { + return Ok(()); + } + let mut d = (*st.doc).clone(); + let sel = st.selection.clone(); + if let Some(body) = body_mut(&mut d, &sel, t) { + ed::format(body, a, b, &|r| r.baseline = Some(0.3)); + } + st.doc = Arc::new(d); + st.revision += 1; + Ok(()) +} + fn autocorrect_options(s: &mut Session, p: &Value) -> Result { let pr = &mut s.prefs; for (key, slot) in [ @@ -684,6 +726,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result { ("days", &mut pr.ac_days), ("capsLock", &mut pr.ac_caps_lock), ("fractions", &mut pr.ac_fractions), + ("ordinals", &mut pr.ac_ordinals), ] { if let Some(v) = p.get(key).and_then(Value::as_bool) { *slot = v; @@ -730,7 +773,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result { Ok(json!({ "on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps, "days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions, - "entries": list, + "ordinals": pr.ac_ordinals, "entries": list, })) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ac25d666..418bff04 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -324,12 +324,13 @@ pub struct Prefs { pub smart_quotes: bool, /// AutoCorrect options (Tools ▸ AutoCorrect): fix TWo INitial CApitals, capitalize the first /// letter of sentences and the names of days, fix accidental cAPS lOCK, and replace 1/2, 1/4 - /// and 3/4 with fraction characters. + /// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) as you type. pub ac_two_initials: bool, pub ac_sentence_caps: bool, pub ac_days: bool, pub ac_caps_lock: bool, pub ac_fractions: bool, + pub ac_ordinals: bool, /// Your own "replace … with …" entries; they win over the built-in list. pub ac_entries: Vec<(String, String)>, /// Built-in entries you deleted. @@ -354,6 +355,7 @@ impl Default for Prefs { ac_days: true, ac_caps_lock: true, ac_fractions: true, + ac_ordinals: true, ac_entries: Vec::new(), ac_removed: Vec::new(), author: "Presenter".into(), diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 707d0aad..646a1434 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -44,6 +44,11 @@ pub fn instance_id() -> u64 { *ID.get_or_init(|| { use std::hash::{BuildHasher, Hasher}; let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + // No PIDs and no std::time on wasm (`std::process::id()` and + // `SystemTime::now()` panic there): the browser clock stands in. + #[cfg(target_arch = "wasm32")] + h.write_u64(js_sys::Date::now() as u64); + #[cfg(not(target_arch = "wasm32"))] h.write_u32(std::process::id()); #[cfg(not(target_arch = "wasm32"))] if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { @@ -311,7 +316,10 @@ mod tests { let opened = open(&mut s2, &dir).unwrap(); assert!(s2.documents().iter().all(|d| d.uid != uid), "{opened:?}"); assert!(lock_path(&dir, other).exists(), "a live owner's lock stays"); - // Once the owner is gone, its entry is recovered and its lock file cleaned up. + // Once the owner is gone, its entry is recovered and its lock file cleaned up. Unlock + // rather than only drop: a child spawned meanwhile by a parallel test (mkfifo) shares the + // open file and would keep the lock held until it execs. + lock.unlock().unwrap(); drop(lock); let mut s3 = Session::new(); open(&mut s3, &dir).unwrap(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index da2c63c3..0d82b746 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -94,6 +94,26 @@ fn text_editing_flow() { assert_eq!(s.doc().unwrap().selection.shapes.len(), 1); } +/// Cut with only a caret copies nothing, so it must not delete the character after the caret. +#[test] +fn cut_with_only_a_caret_changes_nothing() { + let mut s = session(); + let r = s.execute("insert.textBox", &json!({"text": "Hello"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.move", &json!({"to": "start"})).unwrap(); + let out = s.execute("edit.cut", &json!({})).unwrap(); + assert_eq!(out["text"], ""); + let t = s.execute("text.get", &json!({"id": id})).unwrap(); + assert_eq!(t["text"], "Hello", "cut with only a caret changed the text"); + // A real selection is still cut. + s.execute("text.move", &json!({"to": "end", "extend": true})).unwrap(); + let out = s.execute("edit.cut", &json!({})).unwrap(); + assert_eq!(out["text"], "Hello"); + let t = s.execute("text.get", &json!({"id": id})).unwrap(); + assert_eq!(t["text"], ""); +} + #[test] fn editing_text_refits_a_saved_shrink() { use deckcraft_model::text::AutoFit; @@ -2777,6 +2797,33 @@ fn a_png_export_records_the_slide_resolution() { let _ = std::fs::remove_dir_all(&dir); } +/// #784: `transparentBackground` leaves the slide background out of a PNG export, so only the +/// slide's content is opaque; formats without transparency refuse it and write nothing. +#[test] +fn a_png_export_can_leave_the_background_transparent() { + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("design.background", &json!({"color": "#0000C8"})).unwrap(); + let dir = std::env::temp_dir().join(format!("deckcraft-transparent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let read = |path: &std::path::Path| image::load_from_memory(&std::fs::read(path).unwrap()).unwrap().to_rgba8(); + let opaque = dir.join("opaque.png"); + s.execute("file.export", &json!({"path": opaque.to_str().unwrap(), "scale": 1})).unwrap(); + assert_eq!(read(&opaque).get_pixel(5, 5).0, [0, 0, 200, 255], "the background draws by default"); + let clear = dir.join("clear.png"); + s.execute("file.export", &json!({"path": clear.to_str().unwrap(), "scale": 1, "transparentBackground": true})).unwrap(); + let img = read(&clear); + assert_eq!(img.get_pixel(5, 5).0[3], 0, "no background: {:?}", img.get_pixel(5, 5)); + assert_eq!(img.get_pixel(200, 150).0[3], 255, "the shape stays opaque: {:?}", img.get_pixel(200, 150)); + for (name, format) in [("slide.jpg", "jpeg"), ("deck.pdf", "pdf"), ("deck.gif", "gif"), ("deck.pptx", "pptx")] { + let path = dir.join(name); + let e = s.execute("file.export", &json!({"path": path.to_str().unwrap(), "format": format, "transparentBackground": true})).unwrap_err(); + assert!(e.to_string().contains("for PNG export"), "{format}: {e}"); + assert!(!path.exists(), "{format}: nothing is written"); + } + let _ = std::fs::remove_dir_all(&dir); +} + fn png_size(bytes: &[u8]) -> Option<(u32, u32)> { let w: [u8; 4] = bytes.get(16..20)?.try_into().ok()?; let h: [u8; 4] = bytes.get(20..24)?.try_into().ok()?; @@ -3120,6 +3167,37 @@ fn autocorrect_options_as_you_type() { assert_eq!(s.execute("text.get", &json!({"id": title})).unwrap()["text"], "monday notes "); } +#[test] +fn autocorrect_superscripts_ordinals_as_you_type() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + // The typed text with each superscript run in ^(…). + let typed = |s: &mut Session, text: &str| -> String { + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.selectAll", &json!({})).ok(); + s.execute("text.delete", &json!({"dir": "backward"})).ok(); + for c in text.chars() { + s.execute("text.insert", &json!({"text": c.to_string()})).unwrap(); + } + let st = s.doc().unwrap(); + let sh = st.current_slide().unwrap().shapes.iter().find(|sh| sh.id.0 == title).unwrap(); + sh.text.as_ref().unwrap().paragraphs[0] + .runs + .iter() + .map(|r| if r.props.baseline.unwrap_or(0.0) > 0.0 { format!("^({})", r.text) } else { r.text.clone() }) + .collect() + }; + assert_eq!(typed(&mut s, "1st, 22nd and 103rd. 4th 11th x"), "1^(st), 22^(nd) and 103^(rd). 4^(th) 11^(th) x"); + // Not a word, a number without a suffix it takes, or a suffix not yet followed by a space. + assert_eq!(typed(&mut s, "first x1st 1th 2st 21st"), "First x1st 1th 2st 21st"); + assert_eq!(s.execute("text.autocorrect", &json!({})).unwrap()["ordinals"], true); + s.execute("text.autocorrect", &json!({"ordinals": false})).unwrap(); + assert_eq!(typed(&mut s, "2nd "), "2nd "); + // AutoCorrect off turns it off too. + s.execute("text.autocorrect", &json!({"ordinals": true, "on": false})).unwrap(); + assert_eq!(typed(&mut s, "2nd "), "2nd "); +} + #[test] fn theme_variants_apply_and_report_the_current_one() { let mut s = session(); @@ -3517,7 +3595,8 @@ fn background_loads_are_adopted_through_a_command() { replay.execute(&id, ¶ms).unwrap(); assert_eq!(replay.doc().unwrap().doc.slides.len(), 2); // A token staged for another path is not taken for this one. - let other = s.stage_loaded(cmd::file::LoadedFile { path: "/elsewhere.pptx".into(), doc: Default::default(), native: true, damaged: vec![] }); + let other = + s.stage_loaded(cmd::file::LoadedFile { path: "/elsewhere.pptx".into(), doc: Default::default(), native: true, damaged: Default::default() }); s.execute("file.adopt", &json!({"path": path, "token": other})).unwrap(); assert_eq!(s.doc().unwrap().doc.slides.len(), 2); assert!(s.execute("file.adopt", &json!({})).is_err()); @@ -3606,3 +3685,416 @@ fn paragraph_commands_clamp_a_hostile_text_selection() { let t = s.doc().unwrap().selection.text.clone().unwrap(); assert!(t.caret.0 < 1_000, "the caret stayed inside the body: {t:?}"); } + +#[test] +fn paragraph_commands_clamp_a_direct_hostile_text_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.select", &json!({"anchor": [0, 0], "caret": [u64::MAX, 0]})).unwrap(); + s.execute("format.alignCenter", &json!({})).unwrap(); + s.execute("format.case", &json!({"mode": "upper"})).unwrap(); + let body = s.doc().unwrap().shape(deckcraft_model::ShapeId(title)).unwrap().text.as_ref().unwrap(); + assert_eq!(body.paragraphs[0].props.align, Some(deckcraft_model::text::Align::Center)); +} + +#[test] +fn table_column_width_of_a_huge_column_is_ignored() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]])); + for keep_total in [true, false] { + let r = s.execute("table.columnWidth", &json!({"id": id.0, "col": u64::MAX, "width": 50.0, "keepTotal": keep_total})); + assert!(r.is_ok(), "columnWidth must ignore a huge col (keepTotal {keep_total}), got {r:?}"); + assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0], "no column changes"); + } +} + +/// Slides imported from an outline join the section of the slide they follow, like `slide.new`. +#[test] +fn slides_from_outline_join_the_section_of_the_slide_they_follow() { + for markdown in [false, true] { + let mut s = session(); + s.execute("slide.new", &json!({"title": "A"})).unwrap(); + s.execute("section.add", &json!({"name": "B", "at": 1})).unwrap(); + let text = if markdown { "# Imported\n- bullet" } else { "Imported\n\tbullet" }; + s.execute("slide.fromOutline", &json!({"text": text, "markdown": markdown})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let orphans: Vec<_> = doc.slides.iter().map(|x| x.id).filter(|id| doc.section_of(*id).is_none()).collect(); + assert!(orphans.is_empty(), "markdown={markdown}: slides in no section: {orphans:?}; sections {:?}", doc.sections); + let order: Vec<_> = doc.sections.iter().flat_map(|x| x.slides.iter().copied()).collect(); + let slides: Vec<_> = doc.slides.iter().map(|x| x.id).collect(); + assert_eq!(order, slides, "markdown={markdown}: sections follow the slide order"); + } +} + +#[test] +fn opening_a_pptx_with_an_unreadable_slide_says_so() { + use std::io::{Cursor, Read, Write}; + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides.len(), 2); + let good = deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap(); + let mut zin = zip::ZipArchive::new(Cursor::new(good.clone())).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..zin.len() { + let mut f = zin.by_index(i).unwrap(); + let mut d = vec![]; + f.read_to_end(&mut d).unwrap(); + if f.name() == "ppt/slides/slide2.xml" { + d = b" = match &mut sh.kind { + deckcraft_model::ShapeKind::Table(t) => t + .rows + .iter_mut() + .flat_map(|r| r.cells.iter_mut()) + .flat_map(|c| c.text.paragraphs.iter_mut()) + .flat_map(|p| p.runs.iter_mut()) + .collect(), + _ => sh.text.iter_mut().flat_map(|t| t.paragraphs.iter_mut()).flat_map(|p| p.runs.iter_mut()).collect(), + }; + for r in runs { + r.props.glow = glow.clone(); + } + } + // Inherited styles too: the deck default, a master's body style, a text box's own list + // style, and the theme's effect styles that shape styles point at. + let level = deckcraft_model::text::LevelStyle { + run: deckcraft_model::text::RunProps { + glow: Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge }), + ..Default::default() + }, + ..Default::default() + }; + doc.default_text_style.set(0, level.clone()); + let m = std::sync::Arc::make_mut(&mut doc.masters[0]); + m.body_style.set(0, level.clone()); + if let Some(fx) = m.theme.format.effects.first_mut() { + fx.glow = Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge }); + } + let list = crate::shapes_mut(doc, sel).unwrap(); + if let Some(t) = list.iter_mut().find_map(|sh| sh.text.as_mut()) { + t.list_style.set(0, level); + } + Ok(()) + }) + .unwrap(); + for format in ["deckcraft", "pptx"] { + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, format).unwrap(); + let mut t = Session::new(); + t.execute("file.openBytes", &json!({"name": format!("fx.{format}"), "data": cmd::base64_encode(&bytes)})).unwrap(); + let st = t.doc().unwrap(); + let slide = st.doc.slides.last().unwrap(); + let mut glows = vec![]; + deckcraft_model::walk(&slide.shapes, &mut |sh, _| { + if let Some(e) = &sh.effects { + glows.extend(e.glow.as_ref().map(|g| g.radius)); + assert!(e.soft_edge.is_none_or(|v| (0.0..=100.0).contains(&v)), "{format}: {:?}", e.soft_edge); + if let Some(sw) = &e.outer_shadow { + assert!(sw.blur <= 200.0 && sw.dist <= 200.0 && sw.sx <= 10.0, "{format}: {sw:?}"); + } + if let Some(r) = &e.reflection { + assert!(r.blur <= 100.0 && r.dist <= 100.0, "{format}: {r:?}"); + } + } + for p in sh.text.iter().flat_map(|t| t.paragraphs.iter()) { + glows.extend(p.runs.iter().filter_map(|r| r.props.glow.as_ref().map(|g| g.radius))); + } + if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind { + let cells = tb.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.text.paragraphs.iter()); + glows.extend(cells.flat_map(|p| p.runs.iter()).filter_map(|r| r.props.glow.as_ref().map(|g| g.radius))); + } + }); + let inherited = |ls: &deckcraft_model::text::ListStyle| { + ls.levels.iter().flatten().filter_map(|l| l.run.glow.as_ref().map(|g| g.radius)).collect::>() + }; + glows.extend(inherited(&st.doc.default_text_style)); + let m = &st.doc.masters[0]; + for ls in [&m.title_style, &m.body_style, &m.other_style] { + glows.extend(inherited(ls)); + } + glows.extend(m.theme.format.effects.iter().filter_map(|e| e.glow.as_ref().map(|g| g.radius))); + deckcraft_model::walk(&slide.shapes, &mut |sh, _| glows.extend(sh.text.iter().flat_map(|t| inherited(&t.list_style)))); + assert!(!glows.is_empty(), "{format}: the glows were kept"); + assert!(glows.iter().all(|g| (0.0..=150.0).contains(g)), "{format}: {glows:?}"); + // Capped, the slide renders (checked last: uncapped, this is what ran out of memory). + let i = st.doc.slides.len() - 1; + t.execute("file.render", &json!({"slide": i, "scale": 0.1})).unwrap(); + } +} + +/// A .deckcraft file whose shapes repeat an id (groups included) opens with fresh ids for the +/// repeats: the deck passes `validate`, the first shape keeps its id and none is lost. +#[test] +fn opening_a_file_gives_repeated_shape_ids_fresh_ones() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + for x in [0, 100, 200, 300] { + s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 80, 80]})).unwrap(); + } + s.execute("edit.selectAll", &json!({})).unwrap(); + s.execute("arrange.group", &json!({})).unwrap(); + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [400, 300, 80, 80]})).unwrap(); + let first = s.doc().unwrap().shapes()[0].id; + // Every shape, the group's children too, takes the group's id. + s.edit(|doc, sel| { + fn all(shapes: &mut [deckcraft_model::Shape], id: deckcraft_model::ShapeId) { + for sh in shapes { + sh.id = id; + if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind { + all(children, id); + } + } + } + all(crate::shapes_mut(doc, sel).unwrap(), first); + Ok(()) + }) + .unwrap(); + assert!(!s.doc().unwrap().doc.validate().is_empty(), "the deck repeats ids"); + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "deckcraft").unwrap(); + let mut t = Session::new(); + t.execute("file.openBytes", &json!({"name": "dup.deckcraft", "data": cmd::base64_encode(&bytes)})).unwrap(); + let st = t.doc().unwrap(); + assert_eq!(st.doc.validate(), Vec::::new()); + let slide = st.doc.slides.last().unwrap(); + let mut ids = vec![]; + deckcraft_model::walk(&slide.shapes, &mut |sh, _| ids.push(sh.id)); + assert_eq!(ids.len(), 6, "the group, its four children and the ellipse"); + assert_eq!(ids[0], first, "the first shape keeps its id"); + assert!(ids.iter().all(|id| id.0 <= st.doc.next_id), "new ids come from the deck's counter"); + // Each shape can be selected by its own id now. + let last = *ids.last().unwrap(); + t.execute("slide.go", &json!({"index": st.doc.slides.len() - 1})).unwrap(); + t.execute("selection.select", &json!({"id": last.0})).unwrap(); + assert_eq!(t.doc().unwrap().selected_shapes()[0].id, last); +} + +#[test] +fn change_case_spans_formatting_runs() { + let setup = |text: &str, bold_to: usize| { + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 420.0, 160.0], "text": text})).unwrap(); + let id = r["id"].as_u64().unwrap() as u32; + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, bold_to]})).unwrap(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + (s, id) + }; + let text_of = |s: &Session, id: u32| s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap(); + for (mode, input, want) in [("title", "hello world", "Hello World"), ("sentence", "hello world. bye", "Hello world. Bye")] { + // Text range. + let (mut s, id) = setup(input, 3); + let n = input.chars().count(); + s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, n]})).unwrap(); + s.execute("format.case", &json!({"mode": mode})).unwrap(); + assert_eq!(text_of(&s, id).text(), want, "{mode} range"); + // Whole shape. + let (mut s, id) = setup(input, 3); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("format.case", &json!({"mode": mode})).unwrap(); + assert_eq!(text_of(&s, id).text(), want, "{mode} shape"); + assert_eq!(text_of(&s, id).paragraphs[0].runs[0].props.bold, Some(true)); + } + // A length-changing mapping keeps each piece in its own run. + let (mut s, id) = setup("straße", 4); + s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 6]})).unwrap(); + s.execute("format.case", &json!({"mode": "upper"})).unwrap(); + let body = text_of(&s, id); + assert_eq!(body.text(), "STRASSE"); + assert_eq!(body.paragraphs[0].runs[0].text, "STRA"); + assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true)); +} + +#[test] +fn flipping_a_rotated_shape_mirrors_its_outline() { + let corners = |s: &Session, id: deckcraft_model::ShapeId| { + let x = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap(); + let a = x.affine(); + [(0.0, 0.0), (x.w, 0.0), (x.w, x.h), (0.0, x.h)].map(|(px, py)| { + let p = a * deckcraft_geom::Point::new(px, py); + ((p.x * 100.0).round() as i64, (p.y * 100.0).round() as i64) + }) + }; + let sorted = |mut c: [(i64, i64); 4]| { + c.sort_unstable(); + c + }; + for (cmd, horizontal) in [("arrange.flipHorizontal", true), ("arrange.flipVertical", false)] { + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 50]})).unwrap(); + let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32); + s.execute("shape.rotate", &json!({"deg": 30})).unwrap(); + let before = corners(&s, id); + // Mirror about the box's centre line (centre is 200, 125). + let mirrored = before.map(|(x, y)| if horizontal { (40000 - x, y) } else { (x, 25000 - y) }); + s.execute(cmd, &json!({})).unwrap(); + assert_eq!(sorted(corners(&s, id)), sorted(mirrored), "{cmd} of a 30° rotated rectangle is not its mirror image"); + let rot = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().rot; + assert!((rot - 330.0).abs() < 1e-9, "{cmd} negates the rotation, got {rot}"); + s.execute(cmd, &json!({})).unwrap(); + assert_eq!(sorted(corners(&s, id)), sorted(before), "{cmd} twice restores the outline"); + } +} + +#[test] +fn shape_geometry_commands_limit_size_and_position() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "moon", "rect": [100, 100, 200, 100]})).unwrap(); + let xfrm = |s: &Session| s.doc().unwrap().selected_shapes()[0].xfrm.unwrap(); + let within = |s: &Session| { + let x = xfrm(s); + [x.w, x.h].iter().all(|v| v.is_finite() && (0.0..=15_840.0).contains(v)) && [x.x, x.y].iter().all(|v| v.is_finite() && v.abs() <= 100_000.0) + }; + s.execute("shape.setBounds", &json!({"x": 1e100, "y": -1e100, "w": 100000, "h": 1e100})).unwrap(); + let x = xfrm(&s); + assert!(within(&s), "setBounds stored {x:?}"); + s.execute("shape.resize", &json!({"w": 10, "h": 10})).unwrap(); + s.execute("shape.resize", &json!({"h": 1e100})).unwrap(); + let x = xfrm(&s); + assert!(within(&s), "resize stored {x:?}"); + s.execute("shape.resize", &json!({"h": 1e100, "lockAspect": true})).unwrap(); + let x = xfrm(&s); + assert!(within(&s), "locked resize stored {x:?}"); + s.execute("shape.move", &json!({"x": 1e100, "y": -1e100})).unwrap(); + let x = xfrm(&s); + assert!(within(&s), "move stored {x:?}"); + // A move that overflows to infinity lands on the limit, not back at 0. + s.execute("shape.move", &json!({"x": 1e308, "dx": 1e308})).unwrap(); + assert_eq!(xfrm(&s).x, 100_000.0); + // A rotated shape resizes about its centre and still stays inside the limit. + s.execute("shape.setBounds", &json!({"x": 99_000, "y": 0, "w": 10, "h": 10})).unwrap(); + s.execute("shape.rotate", &json!({"deg": 45})).unwrap(); + s.execute("shape.resize", &json!({"w": 1e100})).unwrap(); + let x = xfrm(&s); + assert!(within(&s), "rotated resize stored {x:?}"); +} + +/// Edit Points turns a preset into a freeform: one undo step, so Undo brings the preset back +/// (and leaves Edit Points), Redo the freeform; leaving Edit Points adds no step. +#[test] +fn edit_points_conversion_is_one_undo_step() { + use deckcraft_model::Geom; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "star5", "rect": [100, 100, 200, 100]})).unwrap(); + let preset = |s: &Session| matches!(s.doc().unwrap().selected_shapes()[0].geom, Geom::Preset { .. }); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + assert!(!preset(&s), "Edit Points made a freeform"); + assert!(s.tool.editing_points(s.doc().unwrap()).is_some()); + s.execute("shape.editPoints", &json!({"on": false})).unwrap(); + // Leaving changed nothing, so the next undo is the conversion itself. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(preset(&s), "Undo brings the star back"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(!preset(&s), "Redo makes it a freeform again"); + // Undo while still editing points ends Edit Points: the star has no points to edit. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(preset(&s)); + assert!(s.tool.editing_points(s.doc().unwrap()).is_none()); + // A freeform needs no conversion: entering Edit Points again adds no undo step. + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("shape.editPoints", &json!({"on": false})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(preset(&s), "the only step was the conversion"); +} + +/// #931: while the text of a placeholder is being edited, a shape in front of it still takes a +/// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret. +#[test] +fn a_shape_in_front_of_edited_text_takes_the_click() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap(); + let square = s.execute("shape.insert", &json!({"preset": "rect", "rect": [380, 260, 200, 120]})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0, pressure: None }; + let click = |s: &mut Session, x: f64, y: f64| { + s.pointer(ev(tools::PointerKind::Down, x, y)).unwrap(); + s.pointer(ev(tools::PointerKind::Up, x, y)).unwrap(); + }; + // The content placeholder's edge: its (empty) text is now being edited. + click(&mut s, 66.0, 300.0); + let body = s.doc().unwrap().selection.text.as_ref().map(|t| t.shape).expect("editing the content placeholder"); + assert_ne!(body.0 as u64, square); + // A click on the text away from the square keeps editing it. + click(&mut s, 200.0, 300.0); + assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape), Some(body)); + // A click on the square, in front of the text, selects the square. + click(&mut s, 480.0, 320.0); + let st = s.doc().unwrap(); + assert_eq!(st.selection.shapes.iter().map(|id| id.0 as u64).collect::>(), vec![square]); + assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 == square), "{:?}", st.selection.text); + + // A shape over the edited box's edge takes the click there too: the edge would otherwise drag + // the box. + let edge = { + let st = s.doc().unwrap(); + let x = cmd::xfrm_of(&st.doc, &st.selection, st.shape(body).unwrap()); + (x.x, x.y + x.h / 2.0) + }; + let corner = + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [edge.0 - 10.0, edge.1 - 10.0, 20, 20]})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + s.execute("text.edit", &json!({"id": body.0})).unwrap(); + // On the box's left edge, inside the band that drags it, clear of its middle handle (handles + // stay on top, as in PowerPoint). + click(&mut s, edge.0 + 1.0, edge.1 - 9.0); + let st = s.doc().unwrap(); + assert_eq!(st.selection.shapes.iter().map(|id| id.0 as u64).collect::>(), vec![corner], "{:?}", st.selection); + + // Text inside a group: a member in front of it takes the click instead of the caret. + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let tb = + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 150], "text": "Grouped text"})).unwrap()["id"].as_u64().unwrap(); + let cover = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 150, 60, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("arrange.group", &json!({"ids": [tb, cover]})).unwrap(); + s.execute("text.edit", &json!({"id": tb})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape.0 as u64), Some(tb)); + click(&mut s, 230.0, 170.0); + let st = s.doc().unwrap(); + assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 != tb), "the click on the member in front left the text: {:?}", st.selection); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f1acd9e9..a7190b73 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -280,7 +280,12 @@ impl ToolState { /// The shape in Edit Points, while it is still the one selected shape. pub fn editing_points(&self, st: &crate::DocState) -> Option { - self.edit_points.filter(|id| st.selection.shapes.as_slice() == [*id] && st.selection.text.is_none() && st.shape(*id).is_some()) + // Only a freeform has points: after Undo takes the conversion back, Edit Points is over. + self.edit_points.filter(|id| { + st.selection.shapes.as_slice() == [*id] + && st.selection.text.is_none() + && st.shape(*id).is_some_and(|sh| matches!(sh.geom, deckcraft_model::Geom::Custom { .. })) + }) } /// A freeform path is being drawn (Enter/Escape/double-click finish it). @@ -409,10 +414,12 @@ impl Session { } } } - // The frame of the text being edited: grabbing it moves the box. + // The frame of the text being edited: grabbing it moves the box, unless a shape in front + // of the text covers that spot (#931). if let Some(e) = edited_box(st) && e.movable && edit_zone(&e.xfrm, p, tol) == EditZone::Frame + && !shape_in_front(st, st.shapes(), e.id, p, tol, 0) { return Some(Hit::Frame { id: e.id }); } @@ -1228,7 +1235,10 @@ impl Session { None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside), Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p), }; + // A shape in front of the edited text takes the click, as in PowerPoint (#931). + let in_front = shape_in_front(st, st.shapes(), t.shape, p, tol, 0); if !inside + || in_front || matches!( self.hit_test(p, tol), Some( @@ -1650,6 +1660,27 @@ struct EditedBox { movable: bool, } +/// Whether a visible shape drawn in front of `target` is under `p`: the shapes above it in its +/// list, topmost first, and inside a group that holds it, the members above it. +fn shape_in_front(st: &crate::DocState, shapes: &[Shape], target: ShapeId, p: Point, tol: f64, depth: usize) -> bool { + if depth > 64 { + return false; + } + for s in shapes.iter().rev() { + if s.id == target { + return false; + } + if deckcraft_model::find_shape(std::slice::from_ref(s), target).is_some() { + // The group holding the text: look among its members. + return shape_in_front(st, s.children(), target, p, tol, depth + 1); + } + if !s.hidden && hit_shape(st, s, p, tol).is_some() { + return true; + } + } + false +} + fn edited_box(st: &crate::DocState) -> Option { let t = st.selection.text.as_ref()?; if t.notes || t.cell.is_some() { diff --git a/crates/engine/tests/chart_native.rs b/crates/engine/tests/chart_native.rs new file mode 100644 index 00000000..957be0ad --- /dev/null +++ b/crates/engine/tests/chart_native.rs @@ -0,0 +1,129 @@ +//! Native saves must not discard an imported chart's opaque package parts. + +use std::io::{Cursor, Read}; +use std::sync::Arc; + +use deckcraft_engine::Session; +use deckcraft_engine::cmd::{base64_decode, base64_encode}; +use deckcraft_geom::Xfrm; +use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType}; +use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, defaults}; +use serde_json::json; + +const WORKBOOK: &[u8] = b"original-synthetic-workbook\0\xff\x80"; +const DRAWING: &[u8] = br#""#; +const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +fn chart(p: &Presentation) -> &Chart { + let ShapeKind::Chart(c) = &p.slides[0].shapes[0].kind else { panic!("chart") }; + c +} + +fn source() -> Vec { + let mut p = defaults::new_presentation(None); + let id = ShapeId(p.alloc_id().expect("id")); + Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape { + id, + name: "Owned chart".into(), + xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), + kind: ShapeKind::Chart(Box::new(Chart::sample(ChartType::Pie))), + ..Default::default() + }]; + // Start with our own chart XML, then give it two original, synthetic package parts. + let mut p = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import"); + let ShapeKind::Chart(c) = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("chart") }; + c.raw = c + .raw + .as_ref() + .map(|raw| raw.replace("", r#""#)); + c.kept = ChartKept { + rels: vec![ + ChartRel { + id: "rWorkbook".into(), + rel_type: format!("{RT}/package"), + target: "../embeddings/owned.xlsx".into(), + part: "ppt/embeddings/owned.xlsx".into(), + external: false, + }, + ChartRel { + id: "rDrawing".into(), + rel_type: format!("{RT}/chartUserShapes"), + target: "../drawings/owned.xml".into(), + part: "ppt/drawings/owned.xml".into(), + external: false, + }, + ], + parts: vec![ + ChartPart { + name: "ppt/embeddings/owned.xlsx".into(), + content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(), + bytes: WORKBOOK.to_vec(), + native_entry: None, + }, + ChartPart { + name: "ppt/drawings/owned.xml".into(), + content_type: "application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml".into(), + bytes: DRAWING.to_vec(), + native_entry: None, + }, + ], + }; + deckcraft_pptx::export(&p).expect("PPTX fixture") +} + +fn open(bytes: &[u8], name: &str) -> Session { + let mut s = Session::new(); + s.execute("file.openBytes", &json!({"name":name,"data":base64_encode(bytes)})).expect("open command"); + s +} + +fn save(s: &mut Session, format: &str) -> Vec { + let out = s.execute("file.saveBytes", &json!({"format":format})).expect("save command"); + base64_decode(out["data"].as_str().expect("data")).expect("base64") +} + +#[test] +fn pptx_native_edit_save_reopen_keeps_chart_parts() { + let mut s = open(&source(), "owned.pptx"); + let kept = chart(&s.doc().expect("doc").doc).kept.clone(); + assert_eq!(kept.rels.len(), 2, "fixture relationships must have been imported"); + assert_eq!(kept.parts.len(), 2); + s.execute("file.properties", &json!({"title":"Edited title"})).expect("edit command"); + for _ in 0..2 { + s = open(&save(&mut s, "deckcraft"), "owned.deckcraft"); + let p = &s.doc().expect("doc").doc; + assert_eq!(p.props.title, "Edited title"); + assert_eq!(chart(p).kept, kept); + } + let bytes = save(&mut s, "pptx"); + let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).expect("zip"); + let mut payloads = vec![]; + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + payloads.push((f.name().to_string(), data)); + } + assert!(payloads.iter().any(|(n, d)| n.starts_with("ppt/embeddings/") && d == WORKBOOK)); + assert!(payloads.iter().any(|(n, d)| n.starts_with("ppt/drawings/") && d == DRAWING)); + let back = deckcraft_pptx::import(&bytes).expect("reopen PPTX"); + assert_eq!(chart(&back).kept.rels.len(), 2); + for part in &kept.parts { + assert!(chart(&back).kept.parts.iter().any(|p| p.content_type == part.content_type && p.bytes == part.bytes)); + } +} + +#[test] +fn editing_chart_after_native_reopen_does_not_export_a_stale_workbook() { + let mut s = open(&source(), "owned.pptx"); + s = open(&save(&mut s, "deckcraft"), "owned.deckcraft"); + let doc = s.doc_mut().expect("doc"); + let p = Arc::make_mut(&mut doc.doc); + let ShapeKind::Chart(c) = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("chart") }; + assert_eq!(c.kept.rels.len(), 2); + c.series[0].values[0] = Some(99.0); + let bytes = save(&mut s, "pptx"); + let back = deckcraft_pptx::import(&bytes).expect("PPTX"); + assert_eq!(chart(&back).series[0].values[0], Some(99.0)); + assert!(chart(&back).kept.parts.is_empty(), "stale source workbook must not be attached to edited chart data"); +} diff --git a/crates/engine/tests/show_clicks.rs b/crates/engine/tests/show_clicks.rs index 92da2f7f..42c0f701 100644 --- a/crates/engine/tests/show_clicks.rs +++ b/crates/engine/tests/show_clicks.rs @@ -59,6 +59,34 @@ fn start(p: &mut Presentation) { p.slides.clear(); } +#[test] +fn saved_trigger_reveals_then_hides_on_separate_clicks() { + let mut p = defaults::new_presentation(None); + start(&mut p); + let button = shape(&mut p, "Button", "Toggle", 20.0, 20.0, 100.0, 40.0); + let target = shape(&mut p, "Target", "Answer", 200.0, 100.0, 200.0, 80.0); + let animations = vec![ + Animation { + shape: target.id, + trigger: Some(button.id), + effect: "appear".into(), + start: AnimStart::OnClick, + duration_ms: 0, + ..Default::default() + }, + disappear_on_click(button.id, target.id), + ]; + add_slide(&mut p, vec![button, target], animations, false); + let mut show = Show::open(&p); + assert!(!show.shown("Target")); + for _ in 0..2 { + assert_eq!(show.click("Button"), Click::Animated); + assert!(show.shown("Target"), "first click reveals the answer until the next click"); + assert_eq!(show.click("Button"), Click::Animated); + assert!(!show.shown("Target"), "second click hides the answer"); + } +} + /// The slide show window's click handling, on a deck read from a PPTX file. struct Show { pres: Presentation, diff --git a/crates/format/src/chart_parts.rs b/crates/format/src/chart_parts.rs new file mode 100644 index 00000000..f57c0867 --- /dev/null +++ b/crates/format/src/chart_parts.rs @@ -0,0 +1,112 @@ +//! Keep opaque chart payloads out of the JSON manifest and within the media load budget. +use std::collections::HashSet; +use std::io::Cursor; +use std::sync::Arc; + +use deckcraft_model::chart::ChartPart; +use deckcraft_model::{Master, Presentation, Shape, ShapeKind}; + +use crate::{FormatError, MAX_ENTRY, Result, read_entry_max}; + +const PREFIX: &str = "chart-parts/"; +type Payload = (String, String, Vec); + +pub(super) fn separate(p: &mut Presentation) -> Result> { + let mut payloads = Vec::new(); + visit_parts(p, |part| { + // The native namespace is independent of the original OPC names, which may repeat + // when charts from several decks are combined. No user-supplied path is extracted. + let entry = format!("{PREFIX}{}", payloads.len()); + part.native_entry = Some(entry.clone()); + payloads.push((entry, part.content_type.clone(), std::mem::take(&mut part.bytes))); + Ok(()) + })?; + Ok(payloads) +} + +pub(super) fn restore( + p: &mut Presentation, + z: &mut zip::ZipArchive>, + left: &mut u64, + mut items: usize, + max_items: usize, + damaged: &mut Vec, +) -> Result<()> { + let mut seen = HashSet::new(); + visit_parts(p, |part| { + if items >= max_items { + return Err(FormatError::Damaged(format!("media and chart parts exceed {max_items} items"))); + } + items += 1; + if let Some(entry) = part.native_entry.take() { + // Accept only names produced by separate(), and inflate each member once. + let canonical = entry.strip_prefix(PREFIX).and_then(|s| s.parse::().ok()).is_some_and(|n| entry == format!("{PREFIX}{n}")); + if !canonical || !seen.insert(entry.clone()) || !part.bytes.is_empty() { + return Err(FormatError::Damaged("invalid or ambiguous chart part reference".into())); + } + if z.index_for_name(&entry).is_none() { + // Missing members consume no inflation work and can be reported for recovery. + damaged.push(format!("{entry}: missing chart part")); + return Ok(()); + } + if z.by_name(&entry).is_ok_and(|f| f.size() > *left) { + return Err(FormatError::Damaged("embedded media and chart parts are larger than DeckCraft can open".into())); + } + // A CRC/decode error may already have inflated bytes. Stop on that first error + // rather than letting later corrupt members reuse the unchanged byte budget. + let bytes = read_entry_max(z, &entry, (*left).min(MAX_ENTRY))?; + *left -= bytes.len() as u64; + part.bytes = bytes; + } else { + // Older manifests used JSON byte arrays. Charge those too, after the existing + // bounded manifest parser has read them, so resaving migrates them losslessly. + let size = part.bytes.len() as u64; + if size > (*left).min(MAX_ENTRY) { + return Err(FormatError::Damaged("inline chart part is larger than DeckCraft can open".into())); + } + *left -= size; + } + Ok(()) + }) +} + +fn visit_parts(p: &mut Presentation, mut visit: impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> { + // Use an explicit stack for groups. Charts can also live on masters and their layouts, + // including the notes and handout masters; none of these owners may leak inline bytes. + for slide in &mut p.slides { + shapes(&mut Arc::make_mut(slide).shapes, &mut visit)?; + } + for master in &mut p.masters { + master_parts(Arc::make_mut(master), &mut visit)?; + } + for master in [&mut p.notes_master, &mut p.handout_master].into_iter().flatten() { + master_parts(Arc::make_mut(master), &mut visit)?; + } + Ok(()) +} + +fn master_parts(master: &mut Master, visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> { + shapes(&mut master.shapes, visit)?; + for layout in &mut master.layouts { + shapes(&mut layout.shapes, visit)?; + } + Ok(()) +} + +fn shapes(root: &mut [Shape], visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> { + let mut pending = vec![root]; + while let Some(shapes) = pending.pop() { + for shape in shapes { + match &mut shape.kind { + ShapeKind::Group { children, .. } => pending.push(children), + ShapeKind::Chart(chart) => { + for part in &mut chart.kept.parts { + visit(part)?; + } + } + _ => {} + } + } + } + Ok(()) +} diff --git a/crates/format/src/chart_parts_tests.rs b/crates/format/src/chart_parts_tests.rs new file mode 100644 index 00000000..61a00dc9 --- /dev/null +++ b/crates/format/src/chart_parts_tests.rs @@ -0,0 +1,234 @@ +use super::*; +use deckcraft_model::chart::{Chart, ChartPart, ChartRel, ChartType}; +use deckcraft_model::{Master, Shape, ShapeId}; +use serde_json::{Value, json}; + +const PART: &str = "ppt/embeddings/owned.xlsx"; + +fn fixture(payload: Vec) -> Presentation { + // Construct through the old manifest representation: this also exercises compatibility. + let mut part: ChartPart = serde_json::from_value(json!({ + "name": PART, "contentType": "application/zip", "bytes": [] + })) + .unwrap(); + part.bytes = payload; + let mut chart = Chart::sample(ChartType::Pie); + chart.kept.rels.push(ChartRel { + id: "rWorkbook".into(), + rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(), + target: "../embeddings/owned.xlsx".into(), + part: PART.into(), + external: false, + }); + chart.kept.parts.push(part); + let mut p = Presentation::default(); + let child = Shape { id: ShapeId(p.alloc_id().unwrap()), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() }; + let group = Shape { + id: ShapeId(p.alloc_id().unwrap()), + kind: ShapeKind::Group { children: vec![child], child: Default::default() }, + ..Default::default() + }; + Arc::make_mut(&mut p.slides[0]).shapes = vec![group]; + p +} + +fn part_json(value: &mut Value) -> Option<&mut Value> { + if value.get("name").and_then(Value::as_str) == Some(PART) && value.get("contentType").is_some() { + return Some(value); + } + match value { + Value::Array(values) => values.iter_mut().find_map(part_json), + Value::Object(values) => values.values_mut().find_map(part_json), + _ => None, + } +} + +fn rewrite_manifest(bytes: &[u8], edit: impl FnOnce(&mut Value)) -> Vec { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut json: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap(); + edit(&mut json); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let f = z.by_index(i).unwrap(); + if f.name() == "presentation.json" { + out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(&serde_json::to_vec(&json).unwrap()).unwrap(); + } else { + out.raw_copy_file(f).unwrap(); + } + } + out.finish().unwrap().into_inner() +} + +#[test] +fn large_chart_payload_is_a_referenced_zip_entry() { + let payload = vec![200; 3 * 1024 * 1024]; + let p = fixture(payload.clone()); + let bytes = save(&p).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + assert!(z.by_name("presentation.json").unwrap().size() < 128 * 1024, "binary chart bytes must not become JSON numbers"); + let mut manifest: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap(); + let part = part_json(&mut manifest).unwrap(); + assert!(part.get("bytes").is_none(), "new manifests keep only metadata and a reference"); + let entry = part["nativeEntry"].as_str().unwrap(); + assert!(entry.starts_with("chart-parts/")); + assert_eq!(read_entry(&mut z, entry).unwrap(), payload); + assert!(read_entry_max(&mut z, entry, payload.len() as u64 - 1).is_err()); + assert_eq!(load(&bytes).unwrap(), p); +} + +#[test] +fn chart_parts_share_media_byte_and_item_budgets() { + let mut p = fixture(vec![7; 600]); + p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap(); + let bytes = save(&p).unwrap(); + assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok()); + assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err(), "chart payloads share the cumulative media budget"); + assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err(), "chart parts share the media item budget"); + let duplicated = rewrite_manifest(&bytes, |manifest| { + let media = manifest["presentation"]["media"].as_array_mut().unwrap(); + media.push(media[0].clone()); + }); + assert!(load_within(&duplicated, Budget { total: 1200, items: 2 }).is_err(), "duplicate media IDs still count as listed items"); +} + +#[test] +fn legacy_inline_chart_bytes_are_budgeted_and_preserved() { + let mut p = fixture(vec![9; 600]); + p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap(); + let legacy = rewrite_manifest(&save(&p).unwrap(), |manifest| { + let part = part_json(manifest).unwrap().as_object_mut().unwrap(); + part.remove("nativeEntry"); + part.insert("bytes".into(), json!(vec![9_u8; 600])); + }); + assert_eq!(load_within(&legacy, Budget { total: 1200, items: 2 }).unwrap().0, p); + assert!(load_within(&legacy, Budget { total: 1199, items: 2 }).is_err()); + assert!(load_within(&legacy, Budget { total: 1200, items: 1 }).is_err()); +} + +#[test] +fn chart_payloads_in_every_shape_owner_get_distinct_entries() { + let mut p = fixture(vec![42; 4096]); + let shapes = p.slides[0].shapes.clone(); + let master = Arc::make_mut(&mut p.masters[0]); + master.shapes = shapes.clone(); + master.layouts[0].shapes = shapes.clone(); + p.notes_master = Some(Arc::new(Master { shapes: shapes.clone(), ..Default::default() })); + p.handout_master = Some(Arc::new(Master { shapes, ..Default::default() })); + let bytes = save(&p).unwrap(); + let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + assert_eq!(z.file_names().filter(|name| name.starts_with("chart-parts/")).count(), 5); + assert_eq!(load(&bytes).unwrap(), p); +} + +#[test] +fn missing_or_corrupt_chart_payload_is_reported() { + let p = fixture(b"SYNTHETIC-CHART-PAYLOAD-0123456789".to_vec()); + let bytes = save(&p).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + let mut json: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap(); + let name = part_json(&mut json).unwrap()["nativeEntry"].as_str().unwrap().to_owned(); + let start = z.by_name(&name).unwrap().data_start() as usize; + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let f = z.by_index(i).unwrap(); + if f.name() != name { + out.raw_copy_file(f).unwrap(); + } + } + let missing = out.finish().unwrap().into_inner(); + let (q, damaged) = load_checked(&missing).unwrap(); + assert_eq!(damaged.len(), 1, "{damaged:?}"); + let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") }; + let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") }; + assert!(chart.kept.parts[0].bytes.is_empty()); + let mut corrupt = bytes.clone(); + corrupt[start] ^= 0xff; + assert!(load_checked(&corrupt).is_err()); +} + +#[test] +fn chart_references_cannot_alias_other_members_or_mix_inline_bytes() { + let p = fixture(vec![3; 10]); + let bytes = save(&p).unwrap(); + for entry in ["media/1", "chart-parts/../0", "chart-parts/00", "chart-parts/-1"] { + let changed = rewrite_manifest(&bytes, |manifest| { + part_json(manifest).unwrap()["nativeEntry"] = json!(entry); + }); + assert!(load(&changed).is_err(), "accepted {entry}"); + } + let ambiguous = rewrite_manifest(&bytes, |manifest| { + part_json(manifest).unwrap()["bytes"] = json!([3]); + }); + assert!(load(&ambiguous).is_err()); + + let mut p = p; + let ShapeKind::Group { children, .. } = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("group") }; + let ShapeKind::Chart(chart) = &mut children[0].kind else { panic!("chart") }; + chart.kept.parts.push(chart.kept.parts[0].clone()); + let aliased = rewrite_manifest(&save(&p).unwrap(), |manifest| { + fn alias(value: &mut Value) { + if let Some(entry) = value.get_mut("nativeEntry") { + *entry = json!("chart-parts/0"); + } + match value { + Value::Array(values) => values.iter_mut().for_each(alias), + Value::Object(values) => values.values_mut().for_each(alias), + _ => {} + } + } + alias(manifest); + }); + assert!(load(&aliased).is_err(), "one ZIP member must not be inflated repeatedly"); +} + +fn with_two_parts() -> Presentation { + let mut p = fixture(vec![3; 10]); + let ShapeKind::Group { children, .. } = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("group") }; + let ShapeKind::Chart(chart) = &mut children[0].kind else { panic!("chart") }; + let mut second = chart.kept.parts[0].clone(); + second.bytes = vec![4; 10]; + chart.kept.parts.push(second); + p +} + +#[test] +fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() { + let mut bytes = save(&with_two_parts()).unwrap(); + let offsets: Vec<_> = { + let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + (0..2).map(|i| z.by_name(&format!("chart-parts/{i}")).unwrap().data_start() as usize).collect() + }; + for offset in offsets { + bytes[offset] ^= 0xff; + } + // A failed CRC may already have inflated bytes. Continuing with the same remaining + // budget would let each corrupt member consume that work budget again. + let result = load_within(&bytes, Budget { total: 10, items: 2 }); + assert!(result.is_err(), "two corrupt 10-byte members must not reuse a 10-byte budget"); + assert!(result.unwrap_err().to_string().contains("chart-parts/0")); +} + +#[test] +fn duplicate_physical_zip_names_are_indexed_and_inflated_once() { + let mut bytes = save(&with_two_parts()).unwrap(); + // ZipWriter refuses duplicate names. Forge equal-length local/central-directory names + // instead, to exercise ZipArchive's duplicate-name indexing on an untrusted archive. + let original = b"chart-parts/1"; + let mut replaced = 0; + for offset in 0..=bytes.len() - original.len() { + if &bytes[offset..offset + original.len()] == original { + bytes[offset + original.len() - 1] = b'0'; + replaced += 1; + } + } + assert_eq!(replaced, 2); + let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + assert_eq!(z.file_names().filter(|n| n.starts_with("chart-parts/")).count(), 1); + let (q, damaged) = load_within(&bytes, Budget { total: 10, items: 2 }).unwrap(); + assert_eq!(damaged.len(), 1, "the second reference is missing, not inflated again"); + let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") }; + let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") }; + assert_eq!(chart.kept.parts[0].bytes, vec![4; 10]); + assert!(chart.kept.parts[1].bytes.is_empty()); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index a9db66e2..093cbaa6 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -1,11 +1,15 @@ //! The native `.deckcraft` format: a zip holding `presentation.json` (the model, minus media -//! bytes), `media/` files with the embedded media, and `mimetype`. Readers ignore unknown +//! bytes), `media/` and `chart-parts/` binary entries, and `mimetype`. Readers ignore unknown //! fields, so newer files open in older builds with what those builds understand. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod chart_parts; mod finite; +#[cfg(test)] +mod chart_parts_tests; + use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -20,9 +24,9 @@ pub const EXTENSION: &str = "deckcraft"; pub const VERSION: u32 = 1; /// Largest entry we will inflate (guards against zip bombs). const MAX_ENTRY: u64 = 2 << 30; -/// All embedded media together, inflated (#393). +/// All embedded media and chart sidecars together, inflated (#393). const MAX_TOTAL_MEDIA: u64 = 4 << 30; -/// Media items one file may list. +/// Media items and chart sidecar parts one file may list. const MAX_MEDIA_ITEMS: usize = 100_000; /// What one load may inflate. @@ -62,12 +66,10 @@ pub fn save(p: &Presentation) -> Result> { let w = |e: zip::result::ZipError| FormatError::Write(e.to_string()); z.start_file("mimetype", stored).map_err(w)?; z.write_all(MIMETYPE.as_bytes()).map_err(|e| FormatError::Write(e.to_string()))?; - let m = Manifest { - format: "deckcraft".into(), - version: VERSION, - generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")), - presentation: p.clone(), - }; + let mut presentation = p.clone(); + let parts = chart_parts::separate(&mut presentation)?; + let m = + Manifest { format: "deckcraft".into(), version: VERSION, generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")), presentation }; let json = serde_json::to_vec(&finite::Finite(&m)).map_err(|e| FormatError::Write(e.to_string()))?; z.start_file("presentation.json", deflate).map_err(w)?; z.write_all(&json).map_err(|e| FormatError::Write(e.to_string()))?; @@ -80,6 +82,11 @@ pub fn save(p: &Presentation) -> Result> { z.start_file(format!("media/{}", item.id.0), opts).map_err(w)?; z.write_all(&item.data).map_err(|e| FormatError::Write(e.to_string()))?; } + for (name, content_type, bytes) in parts { + let opts = if is_compressed(&content_type) { stored } else { deflate }; + z.start_file(name, opts).map_err(w)?; + z.write_all(&bytes).map_err(|e| FormatError::Write(e.to_string()))?; + } z.finish().map_err(w)?; } Ok(buf.into_inner()) @@ -119,14 +126,15 @@ fn read_entry_max(z: &mut zip::ZipArchive>, name: &str, max: u64) Ok(out) } -/// Read a presentation. Embedded media that is missing or damaged is left empty; use -/// [`load_checked`] to learn which. +/// Read a presentation. Missing media/chart parts are left empty; use [`load_checked`] to +/// learn which. A damaged chart member refuses the file before further chart inflation. pub fn load(bytes: &[u8]) -> Result { load_checked(bytes).map(|(p, _)| p) } -/// Read a presentation and list the embedded media that couldn't be read (missing, too large -/// or failing its CRC), one message each. Linked media saved without its bytes isn't damage. +/// Read a presentation and list media that couldn't be read, plus missing chart members. +/// A chart member that is present but fails decoding, CRC or its entry limit refuses the file. +/// Linked media saved without its bytes isn't damage. pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec)> { load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS }) } @@ -140,7 +148,8 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec budget.items { + let items = p.media.len(); + if items > budget.items { return Err(FormatError::Damaged(format!("{} media items (at most {})", p.media.len(), budget.items))); } // A repeated id names the same member: keep the first item, so the member is inflated once @@ -163,6 +172,7 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec damaged.push(e.to_string()), } } + chart_parts::restore(&mut p, &mut z, &mut left, items, budget.items, &mut damaged)?; repair(&mut p); Ok((p, damaged)) } @@ -197,6 +207,7 @@ pub fn repair(p: &mut Presentation) { } } } + reid_duplicate_shapes(p); p.slide_size = sane_slide_size(p.slide_size); // The notes page only lays out notes, but its size divides too. if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) { @@ -205,6 +216,57 @@ pub fn repair(p: &mut Presentation) { sane_numbers(p); } +/// Give a shape that repeats an id already used on its slide, layout or master (groups included) +/// a fresh one, so edits, selection and the Selection Pane find one shape per id. The first keeps +/// its id, and with it what animations and connectors point at. An exhausted id space leaves the +/// rest as they are (#407). +fn reid_duplicate_shapes(p: &mut Presentation) { + fn has_duplicates(shapes: &[deckcraft_model::Shape]) -> bool { + let mut seen = std::collections::HashSet::new(); + let mut dup = false; + deckcraft_model::walk(shapes, &mut |sh, _| dup |= !seen.insert(sh.id)); + dup + } + fn reid(shapes: &mut [deckcraft_model::Shape], seen: &mut std::collections::HashSet, next: &mut u32, depth: usize) { + if depth > 64 { + return; + } + for sh in shapes { + if !seen.insert(sh.id) + && let Some(id) = (*next).max(1).checked_add(1) + { + *next = id; + sh.id = deckcraft_model::ShapeId(id); + seen.insert(sh.id); + } + if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind { + reid(children, seen, next, depth + 1); + } + } + } + let mut next = p.next_id; + let mut fix = |shapes: &mut Vec| { + if has_duplicates(shapes) { + reid(shapes, &mut std::collections::HashSet::new(), &mut next, 0); + } + }; + for s in &mut p.slides { + if has_duplicates(&s.shapes) { + fix(&mut Arc::make_mut(s).shapes); + } + } + for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) { + if has_duplicates(&m.shapes) || m.layouts.iter().any(|l| has_duplicates(&l.shapes)) { + let m = Arc::make_mut(m); + fix(&mut m.shapes); + for l in &mut m.layouts { + fix(&mut l.shapes); + } + } + } + p.next_id = next; +} + /// Smallest and largest slide side, in points: PowerPoint's 1–56 in, as Design › Slide Size allows. pub const SLIDE_SIDE: std::ops::RangeInclusive = 72.0..=4032.0; @@ -350,6 +412,66 @@ fn sane_xfrm(x: &mut deckcraft_model::Xfrm) { fn sane_run(p: &mut deckcraft_model::text::RunProps) { p.size = p.size.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.clamp(1.0, 4000.0)); + for s in p.shadow.iter_mut().chain(p.inner_shadow.iter_mut()) { + sane_shadow(s); + } + if let Some(g) = p.glow.as_mut() { + g.radius = effect_size(g.radius, MAX_GLOW); + } + if let Some(r) = p.reflection.as_mut() { + sane_reflection(r); + } + p.soft_edge = p.soft_edge.map(|v| effect_size(v, MAX_SOFT_EDGE)); +} + +/// The largest effect sizes, in points: what Format Shape and `shape.effects` allow. A file can +/// say anything, and the renderer's memory grows with these (a glow of 10^18 pt asked for 34 GB). +const MAX_GLOW: f64 = 150.0; +const MAX_SOFT_EDGE: f64 = 100.0; +const MAX_SHADOW: f64 = 200.0; +const MAX_REFLECTION: f64 = 100.0; + +/// `v` within 0..=`max`; not a number is 0. +fn effect_size(v: f64, max: f64) -> f64 { + if v.is_finite() { v.clamp(0.0, max) } else { 0.0 } +} + +fn sane_shadow(s: &mut deckcraft_model::style::Shadow) { + s.blur = effect_size(s.blur, MAX_SHADOW); + s.dist = effect_size(s.dist, MAX_SHADOW); + s.dir = if s.dir.is_finite() { s.dir.rem_euclid(360.0) } else { 0.0 }; + // Perspective scale (1 = 100%) and skew (degrees). + for v in [&mut s.sx, &mut s.sy] { + *v = if v.is_finite() { v.clamp(-10.0, 10.0) } else { 1.0 }; + } + for v in [&mut s.kx, &mut s.ky] { + *v = if v.is_finite() { v.clamp(-89.0, 89.0) } else { 0.0 }; + } +} + +fn sane_reflection(r: &mut deckcraft_model::style::Reflection) { + r.blur = effect_size(r.blur, MAX_REFLECTION); + r.dist = effect_size(r.dist, MAX_REFLECTION); +} + +/// Run properties a list style hands down to the text that inherits it. +fn sane_list_style(ls: &mut deckcraft_model::text::ListStyle) { + for level in ls.levels.iter_mut().flatten() { + sane_run(&mut level.run); + } +} + +fn sane_effects(e: &mut deckcraft_model::Effects) { + for s in e.outer_shadow.iter_mut().chain(e.inner_shadow.iter_mut()) { + sane_shadow(s); + } + if let Some(g) = e.glow.as_mut() { + g.radius = effect_size(g.radius, MAX_GLOW); + } + e.soft_edge = e.soft_edge.map(|v| effect_size(v, MAX_SOFT_EDGE)); + if let Some(r) = e.reflection.as_mut() { + sane_reflection(r); + } } fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) { @@ -363,7 +485,11 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) { if let Some(l) = sh.line.as_mut() { l.width = l.width.filter(|v| v.is_finite() && *v >= 0.0).map(|v| v.min(1584.0)); } + if let Some(e) = sh.effects.as_mut() { + sane_effects(e); + } if let Some(t) = sh.text.as_mut() { + sane_list_style(&mut t.list_style); for para in &mut t.paragraphs { sane_run(&mut para.end_props); for r in &mut para.runs { @@ -381,6 +507,17 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) { sane_xfrm(child); sane_shapes(children, depth + 1); } + deckcraft_model::ShapeKind::Table(t) => { + for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + sane_list_style(&mut cell.text.list_style); + } + for para in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()).flat_map(|c| c.text.paragraphs.iter_mut()) { + sane_run(&mut para.end_props); + for r in &mut para.runs { + sane_run(&mut r.props); + } + } + } _ => {} } } @@ -395,12 +532,21 @@ pub fn sane_numbers(p: &mut Presentation) { *slide = Arc::new(fixed); } } + // What text and shapes inherit: the deck's default text style, each master's title, body and + // other styles, and the theme's effect styles that shape styles point at. + sane_list_style(&mut p.default_text_style); for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) { let mut fixed = (**m).clone(); sane_shapes(&mut fixed.shapes, 0); for l in &mut fixed.layouts { sane_shapes(&mut l.shapes, 0); } + for ls in [&mut fixed.title_style, &mut fixed.body_style, &mut fixed.other_style] { + sane_list_style(ls); + } + for e in &mut fixed.theme.format.effects { + sane_effects(e); + } if fixed != **m { *m = Arc::new(fixed); } @@ -492,7 +638,8 @@ pub fn markdown_to_slides(p: &mut Presentation, text: &str) -> usize { pub fn slides_to_outline(p: &Presentation) -> String { let mut out = String::new(); for s in &p.slides { - out.push_str(&s.title()); + // One line per title: a newline would start another slide on re-import. + out.push_str(&s.title().replace('\n', " ")); out.push('\n'); for sh in &s.shapes { if sh.ph_type().is_some_and(|k| k.is_title()) { @@ -526,6 +673,54 @@ pub fn slides_to_outline(p: &Presentation) -> String { mod tests { use super::*; + #[test] + fn chart_sidecars_are_optional_for_older_native_files() { + use deckcraft_model::chart::{Chart, ChartType}; + let chart = Chart::sample(ChartType::Pie); + let mut json = serde_json::to_value(&chart).expect("chart JSON"); + assert!(json.get("kept").is_none(), "empty sidecars should not enlarge ordinary files"); + assert_eq!(serde_json::from_value::(json.clone()).expect("old chart without kept"), chart); + json["kept"] = serde_json::json!({}); + assert_eq!(serde_json::from_value::(json).expect("empty sidecar"), chart); + } + + #[test] + fn native_save_keeps_chart_relationships_and_payloads() { + use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType}; + use deckcraft_model::{Shape, ShapeId, ShapeKind}; + let kept = ChartKept { + rels: vec![ChartRel { + id: "rWorkbook".into(), + rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(), + target: "../embeddings/data.xlsx".into(), + part: "ppt/embeddings/data.xlsx".into(), + external: false, + }], + parts: vec![ChartPart { + name: "ppt/embeddings/data.xlsx".into(), + content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(), + bytes: vec![0, 255, 128, 42], + native_entry: None, + }], + }; + let mut p = defaults::new_presentation(None); + let chart = Chart { kept: kept.clone(), ..Chart::sample(ChartType::Pie) }; + let s = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() }; + // Serialization must reach charts inside groups as well as top-level charts. + Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape { + id: ShapeId(p.alloc_id().expect("group id")), + kind: ShapeKind::Group { children: vec![s], child: Default::default() }, + ..Default::default() + }]; + let mut q = load(&save(&p).expect("save")).expect("load"); + for _ in 0..2 { + let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") }; + let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") }; + assert_eq!(chart.kept, kept); + q = load(&save(&q).expect("save again")).expect("load again"); + } + } + /// The same package without the entries `drop` names. fn without(bytes: &[u8], drop: &str) -> Vec { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); @@ -832,6 +1027,18 @@ mod tests { assert!(o.contains("-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\nBullets\n\tPoint\n\tOther"), "{o}"); } + #[test] + fn outline_export_keeps_multi_paragraph_title_on_one_line() { + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + assert_eq!(outline_to_slides(&mut p, "Intro\n\tPoint one\nSecond\n\tMore"), 2); + let title = Arc::make_mut(&mut p.slides[0]).shapes.iter_mut().find(|s| s.ph_type().is_some_and(|k| k.is_title())).expect("title placeholder"); + title.text = Some(TextBody::from_text("Line one\nLine two")); + let o = slides_to_outline(&p); + assert_eq!(o, "Line one Line two\n\tPoint one\nSecond\n\tMore\n"); + let mut q = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + assert_eq!(outline_to_slides(&mut q, &o), 2); + } + #[test] fn markdown_headings_and_list_items() { assert_eq!(md_heading("# Intro"), Some("Intro")); diff --git a/crates/media/tests/fixtures/make.sh b/crates/media/tests/fixtures/make.sh index bf09b67b..083284a4 100755 --- a/crates/media/tests/fixtures/make.sh +++ b/crates/media/tests/fixtures/make.sh @@ -20,12 +20,13 @@ $FF $TONE44 -c:a wmav2 -b:a 64k $META tone.wma # Video: 64x48 (AV1: 64x64), 10 fps, 6 frames of our own moving colour gradient (raw RGB from # python3 below), so every pixel is contributor-original. frames() { + width="$1" height="$2" out="$3" python3 -c " import sys -w, h = $1, $2 +w, h = $width, $height for f in range(6): sys.stdout.buffer.write(bytes(v for y in range(h) for x in range(w) for v in ((x * 4 + f * 20) % 256, (y * 5) % 256, (f * 40) % 256))) -" > "$3" +" > "$out" } frames 64 48 /tmp/deckcraft-fixture-64x48.rgb frames 64 64 /tmp/deckcraft-fixture-64x64.rgb diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 36dd9708..f5aeb1ee 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -1,1441 +1,1455 @@ -//! Charts: an embedded data grid plus type and element options. - -use serde::{Deserialize, Serialize}; - -use crate::style::{ColorRef, Fill, Line}; - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ChartType { - #[default] - Column, - StackedColumn, - PercentColumn, - Bar, - StackedBar, - PercentBar, - Line, - LineMarkers, - StackedLine, - Area, - StackedArea, - Pie, - Doughnut, - Scatter, - Bubble, - Radar, - FilledRadar, - Stock, - Surface, - Histogram, - Pareto, - BoxWhisker, - Waterfall, - Funnel, - Treemap, - Sunburst, - /// A filled (choropleth) map of regions coloured by value, kept and drawn from its data; it - /// is not offered in the chart menus (PowerPoint needs an online map service to make one). - RegionMap, - Combo, -} - -impl ChartType { - /// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus. - pub const OFFICE_2016: [ChartType; 7] = [ - ChartType::Waterfall, - ChartType::Funnel, - ChartType::Histogram, - ChartType::Pareto, - ChartType::BoxWhisker, - ChartType::Treemap, - ChartType::Sunburst, - ]; - - /// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback). - pub fn is_chartex(self) -> bool { - Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap - } - - pub const MAIN: [ChartType; 12] = [ - ChartType::Column, - ChartType::Bar, - ChartType::Line, - ChartType::Area, - ChartType::Pie, - ChartType::Doughnut, - ChartType::Scatter, - ChartType::Radar, - ChartType::Waterfall, - ChartType::Funnel, - ChartType::Histogram, - ChartType::Treemap, - ]; - pub fn label(self) -> &'static str { - match self { - ChartType::Column => "Clustered Column", - ChartType::StackedColumn => "Stacked Column", - ChartType::PercentColumn => "100% Stacked Column", - ChartType::Bar => "Clustered Bar", - ChartType::StackedBar => "Stacked Bar", - ChartType::PercentBar => "100% Stacked Bar", - ChartType::Line => "Line", - ChartType::LineMarkers => "Line with Markers", - ChartType::StackedLine => "Stacked Line", - ChartType::Area => "Area", - ChartType::StackedArea => "Stacked Area", - ChartType::Pie => "Pie", - ChartType::Doughnut => "Doughnut", - ChartType::Scatter => "Scatter", - ChartType::Bubble => "Bubble", - ChartType::Radar => "Radar", - ChartType::FilledRadar => "Filled Radar", - ChartType::Stock => "Stock", - ChartType::Surface => "Surface", - ChartType::Histogram => "Histogram", - ChartType::Pareto => "Pareto", - ChartType::BoxWhisker => "Box and Whisker", - ChartType::Waterfall => "Waterfall", - ChartType::Funnel => "Funnel", - ChartType::Treemap => "Treemap", - ChartType::Sunburst => "Sunburst", - ChartType::RegionMap => "Map", - ChartType::Combo => "Combo", - } - } - pub fn is_bar_like(self) -> bool { - matches!( - self, - ChartType::Column - | ChartType::StackedColumn - | ChartType::PercentColumn - | ChartType::Bar - | ChartType::StackedBar - | ChartType::PercentBar - | ChartType::Histogram - | ChartType::Pareto - | ChartType::Waterfall - ) - } - pub fn horizontal(self) -> bool { - matches!(self, ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel) - } - pub fn stacked(self) -> bool { - matches!( - self, - ChartType::StackedColumn - | ChartType::StackedBar - | ChartType::StackedLine - | ChartType::StackedArea - | ChartType::PercentColumn - | ChartType::PercentBar - ) - } - pub fn percent(self) -> bool { - matches!(self, ChartType::PercentColumn | ChartType::PercentBar) - } -} - -/// `c:trendlineType`: the six trendlines DrawingML charts store. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum TrendlineType { - Exp, - #[default] - Linear, - Log, - MovingAvg, - Poly, - Power, -} - -impl TrendlineType { - pub fn xml(self) -> &'static str { - match self { - TrendlineType::Exp => "exp", - TrendlineType::Linear => "linear", - TrendlineType::Log => "log", - TrendlineType::MovingAvg => "movingAvg", - TrendlineType::Poly => "poly", - TrendlineType::Power => "power", - } - } - pub fn from_xml(s: &str) -> Option { - Some(match s { - "exp" => TrendlineType::Exp, - "linear" => TrendlineType::Linear, - "log" => TrendlineType::Log, - "movingAvg" => TrendlineType::MovingAvg, - "poly" => TrendlineType::Poly, - "power" => TrendlineType::Power, - _ => return None, - }) - } -} - -/// One `c:trendline` on a series. Polynomials use `order` (2–6); moving averages use `period`. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Trendline { - pub kind: TrendlineType, - /// `c:name`, when the file sets one. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Polynomial order (`c:order`). - #[serde(skip_serializing_if = "Option::is_none")] - pub order: Option, - /// Moving-average period (`c:period`). - #[serde(skip_serializing_if = "Option::is_none")] - pub period: Option, - /// Forecast forward and backward, in category units (`c:forward`, `c:backward`). - #[serde(skip_serializing_if = "Option::is_none")] - pub forward: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub backward: Option, - /// Cross the value axis at this value (`c:intercept`). - #[serde(skip_serializing_if = "Option::is_none")] - pub intercept: Option, - /// Show the equation (`c:dispEq`). - pub disp_eq: bool, - /// Show R² (`c:dispRSqr`). - pub disp_r_sqr: bool, -} - -impl Default for Trendline { - fn default() -> Self { - Trendline { - kind: TrendlineType::Linear, - name: None, - order: None, - period: None, - forward: None, - backward: None, - intercept: None, - disp_eq: false, - disp_r_sqr: false, - } - } -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Series { - pub name: String, - pub values: Vec>, - /// X values for scatter/bubble. - pub x_values: Vec>, - pub sizes: Vec>, - pub fill: Option, - pub line: Option, - /// Per-point fill overrides (pie slices). - pub point_fills: Vec<(usize, Fill)>, - pub data_labels: Option, - pub smooth: bool, - pub marker: Option, - /// Combo charts: this series' own type. - pub kind: Option, - pub secondary_axis: bool, - /// The number format of the series' data labels (Excel code): the labels' own, else the - /// source data's. - #[serde(skip_serializing_if = "Option::is_none")] - pub number_format: Option, - /// Trendlines (`c:trendline`), in file order. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub trendlines: Vec, - /// `c:errBars` on the values (y bars; a bar chart's run along its value axis). - #[serde(skip_serializing_if = "Option::is_none")] - pub error_bars: Option, -} - -/// Error bars (`c:errBars`): how far each point's bar reaches below and above it. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct ErrorBars { - /// `both`, `plus` or `minus`. - pub bar_type: String, - /// `fixedVal`, `percentage`, `stdDev`, `stdErr` or `cust`. - pub val_type: String, - pub value: f64, - /// `cust`: the per-point plus and minus amounts. - pub plus: Vec>, - pub minus: Vec>, - pub end_cap: bool, -} - -impl Default for ErrorBars { - fn default() -> Self { - ErrorBars { bar_type: "both".into(), val_type: "fixedVal".into(), value: 1.0, plus: vec![], minus: vec![], end_cap: true } - } -} - -/// Count, mean and sample standard deviation of a series' finite values (for error bars). -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct SeriesStats { - pub n: f64, - pub mean: f64, - pub sd: f64, -} - -impl SeriesStats { - pub fn of(values: &[Option]) -> Self { - let vals = || values.iter().flatten().copied().filter(|v| v.is_finite()); - let n = vals().count() as f64; - let mean = vals().sum::() / n.max(1.0); - let sd = if n > 1.0 { (vals().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0)).sqrt() } else { 0.0 }; - SeriesStats { n, mean, sd } - } -} - -impl ErrorBars { - /// Where point `i` (value `v`) of a series with `values` has its bar: (low, high) in data units. - /// Standard deviation bars stand around the series' mean, as Excel draws them; the others - /// around the point. `None` for an amount that isn't a finite number. - pub fn range(&self, values: &[Option], i: usize, v: f64) -> Option<(f64, f64)> { - self.range_in(&SeriesStats::of(values), i, v) - } - - /// [`ErrorBars::range`] with the series' statistics worked out once ([`SeriesStats::of`]), so - /// drawing every point of a long series stays linear. - pub fn range_in(&self, stats: &SeriesStats, i: usize, v: f64) -> Option<(f64, f64)> { - let SeriesStats { n, mean, sd } = *stats; - let (centre, minus, plus) = match self.val_type.as_str() { - "percentage" => (v, (v * self.value / 100.0).abs(), (v * self.value / 100.0).abs()), - "stdDev" => (mean, sd * self.value, sd * self.value), - "stdErr" => (v, sd / n.max(1.0).sqrt(), sd / n.max(1.0).sqrt()), - "cust" => (v, self.minus.get(i).copied().flatten().unwrap_or(0.0), self.plus.get(i).copied().flatten().unwrap_or(0.0)), - _ => (v, self.value, self.value), - }; - let (lo, hi) = match self.bar_type.as_str() { - "plus" => (centre, centre + plus), - "minus" => (centre - minus, centre), - _ => (centre - minus, centre + plus), - }; - (lo.is_finite() && hi.is_finite()).then_some((lo, hi)) - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Chart { - pub kind: ChartType, - pub title: Option, - pub categories: Vec, - pub series: Vec, - pub legend: Option, - /// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it. - pub legend_overlay: bool, - /// Text size in points of the axis labels and legend (from their `c:txPr`, else the chart's); - /// `None`: the file gives none. - #[serde(skip_serializing_if = "Option::is_none")] - pub text_size: Option, - /// Title text size in points (its rich text or `c:txPr`). - #[serde(skip_serializing_if = "Option::is_none")] - pub title_size: Option, - pub data_labels: bool, - pub gridlines: bool, - /// `c:minorGridlines` on the value axis: five to a major unit, between the major ones. - pub minor_gridlines: bool, - pub axis_titles: (Option, Option), - /// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…). - pub style: u32, - pub palette: String, - pub plot_fill: Option, - pub chart_fill: Option, - pub text_color: Option, - pub hole_size: f64, - pub gap_width: f64, - pub overlap: f64, - pub vary_colors: bool, - pub y_min: Option, - pub y_max: Option, - /// The value axis' major unit, when the chart fixes it. - pub y_major: Option, - /// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000). - pub y_log_base: Option, - /// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`). - pub y_format: Option, - /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. - pub y_hidden: bool, - /// The category axis runs backwards (`c:orientation val="maxMin"`): right to left, or on a - /// horizontal bar chart top to bottom (the default puts the first category at the bottom). - pub cat_reversed: bool, - /// A combo chart's secondary value axis: bounds, major unit and number format. - pub y2_min: Option, - pub y2_max: Option, - pub y2_major: Option, - pub y2_format: Option, - pub y2_hidden: bool, - /// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark. - pub lang: Option, - /// A waterfall's total points (`cx:subtotals`): bars that stand on the axis and restart - /// the running total, instead of floating. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub subtotals: Vec, - /// Outer category levels of a treemap or sunburst, innermost first (the leaves' parents, - /// then theirs…), each with one label per category. A blank label continues the one above it. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub category_levels: Vec>, - /// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub inclusive_quartiles: bool, - /// A stock chart's up/down bars (`c:upDownBars`): open-high-low-close, the box between - /// the first and last series, hollow when the price rose. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub up_down_bars: bool, - /// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are - /// automatic (Scott's rule, as Excel does). - #[serde(skip_serializing_if = "Option::is_none")] - pub bin_count: Option, - /// A histogram's bin width (`cx:binSize`). - #[serde(skip_serializing_if = "Option::is_none")] - pub bin_size: Option, - /// Original chart XML part kept for round-trip of unmodelled options. - #[serde(skip_serializing_if = "Option::is_none")] - pub raw: Option, - /// Package parts the original chart still points at (embedded workbook, styles, drawings). - /// The writer clears this before comparing modelled state. A native save does not store the bytes. - #[serde(skip)] - pub kept: ChartKept, -} - -/// One relationship of a chart part, with the target string as it was written. -#[derive(Clone, Debug, PartialEq)] -pub struct ChartRel { - pub id: String, - pub rel_type: String, - pub target: String, - /// Absolute name of the part an internal relationship reaches (matches a `ChartPart::name` - /// up to case); empty for external ones. The writer renames kept parts and uses this to - /// point the relationship at the new name. - pub part: String, - pub external: bool, -} - -/// A part reached from a chart (the workbook, a drawing, an image it uses). -#[derive(Clone, Debug, PartialEq)] -pub struct ChartPart { - /// Absolute part name (`ppt/embeddings/Workbook.xlsx`). - pub name: String, - pub content_type: String, - pub bytes: Vec, -} - -/// Sidecar for an unchanged chart: relationships and the parts they reach. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct ChartKept { - pub rels: Vec, - pub parts: Vec, -} - -impl Default for Chart { - fn default() -> Self { - Chart { - kind: ChartType::Column, - title: Some("Chart Title".into()), - categories: vec![], - series: vec![], - legend: Some("b".into()), - legend_overlay: false, - text_size: None, - title_size: None, - data_labels: false, - gridlines: true, - minor_gridlines: false, - axis_titles: (None, None), - style: 2, - palette: "colorful1".into(), - plot_fill: None, - chart_fill: None, - text_color: None, - hole_size: 0.5, - gap_width: 2.19, - overlap: -0.27, - vary_colors: false, - y_min: None, - y_max: None, - y_major: None, - y_log_base: None, - y_format: None, - y_hidden: false, - cat_reversed: false, - y2_min: None, - y2_max: None, - y2_major: None, - y2_format: None, - y2_hidden: false, - lang: None, - subtotals: vec![], - category_levels: vec![], - inclusive_quartiles: false, - up_down_bars: false, - bin_count: None, - bin_size: None, - raw: None, - kept: ChartKept::default(), - } - } -} - -impl Chart { - /// The sample data a new chart starts with (our own numbers). - pub fn sample(kind: ChartType) -> Self { - let cats = ["Q1", "Q2", "Q3", "Q4"]; - let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]]; - let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() }; - if matches!(kind, ChartType::Treemap | ChartType::Sunburst) { - // A two-level hierarchy: months inside quarters. - c.categories = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(|s| s.to_string()).collect(); - c.category_levels = vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(|s| s.to_string()).collect()]; - let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0]; - c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); - c.vary_colors = true; - return c; - } - if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { - // These plot raw observations: a histogram bins one series, a box chart summarises - // each category's points. - let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0]; - let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 }; - c.categories = if kind == ChartType::BoxWhisker { - (0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect() - } else { - vec![] - }; - for s in 0..n_series { - c.series.push(Series { - name: format!("Series {}", s + 1), - values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(), - ..Default::default() - }); - } - c.gridlines = true; - c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string()); - return c; - } - if kind == ChartType::Stock { - // High-low-close over five days, the series in the order PowerPoint expects. - c.categories = ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(|s| s.to_string()).collect(); - let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])]; - for (name, v) in hlc { - c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); - } - return c; - } - let n_series = - if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 }; - for s in 0..n_series { - c.series.push(Series { - name: format!("Series {}", s + 1), - values: data.iter().map(|row| row.get(s).copied()).collect(), - x_values: if kind == ChartType::Scatter { (1..=4).map(|x| Some(x as f64)).collect() } else { vec![] }, - ..Default::default() - }); - } - c.vary_colors = n_series == 1; - if kind == ChartType::Combo { - // Columns with the last series as a line, as PowerPoint's combo starts. - for (i, s) in c.series.iter_mut().enumerate() { - s.kind = Some(if i + 1 == n_series { ChartType::Line } else { ChartType::Column }); - } - } - c - } - /// A series' own chart type: its own in a combo chart (columns when it names none), else the - /// chart's. - pub fn series_kind(&self, s: &Series) -> ChartType { - match (self.kind, s.kind) { - (ChartType::Combo, Some(k)) if k != ChartType::Combo => k, - (ChartType::Combo, _) => ChartType::Column, - (k, _) => k, - } - } - - /// Whether a series is plotted against the secondary value axis (combo charts only). - pub fn on_secondary(&self, s: &Series) -> bool { - self.kind == ChartType::Combo && s.secondary_axis - } - - /// The value range of one value axis: the primary axis (`y_min`/`y_max` apply), or a combo - /// chart's secondary axis. A combo chart stacks only its stacked series. `None` when no series - /// uses that axis. - pub fn axis_range(&self, secondary: bool) -> Option<(f64, f64)> { - if self.kind != ChartType::Combo { - return (!secondary).then(|| self.value_range()); - } - let (lo, hi) = self.plotted_extent(secondary)?; - let (lo, hi) = (lo.min(0.0), hi.max(0.0)); - Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) }) - } - - /// The smallest and largest value plotted against one value axis: plain values as they are, - /// stacked series as running sums from zero, 100 % series as 0–1. `(0, 0)` without finite - /// values; `None` when no series uses the axis. - fn plotted_extent(&self, secondary: bool) -> Option<(f64, f64)> { - if self.kind == ChartType::Waterfall { - return (!secondary).then(|| self.value_range()); - } - let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect(); - if ser.is_empty() { - return (!secondary && self.kind != ChartType::Combo).then_some((0.0, 0.0)); - } - let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); - let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0); - let mut percent = false; - for i in 0..n { - let (mut p, mut m, mut stacked) = (0.0, 0.0, false); - for s in &ser { - let k = self.series_kind(s); - let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; - if k.percent() { - percent = true; - } else if k.stacked() { - stacked = true; - if v >= 0.0 { p += v } else { m += v } - } else { - hi = hi.max(v); - lo = lo.min(v); - } - } - if stacked { - hi = hi.max(p); - lo = lo.min(m); - } - } - if !percent { - for s in &ser { - if self.series_kind(s).stacked() { - continue; - } - for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { - hi = hi.max(y); - lo = lo.min(y); - } - } - } - if percent { - lo = lo.min(0.0); - hi = hi.max(1.0); - } - Some(if lo <= hi { (lo, hi) } else { (0.0, 0.0) }) - } - - /// A waterfall's bars as (from, to) on the value axis: each point floats from the running - /// total, a total point stands on zero and sets it. - pub fn waterfall_bars(&self) -> Vec<(f64, f64)> { - let Some(s) = self.series.first() else { return vec![] }; - let mut run = 0.0; - s.values - .iter() - .enumerate() - .map(|(i, v)| { - let v = v.unwrap_or(0.0); - if self.subtotals.contains(&i) { - run = v; - (0.0, v) - } else { - let from = run; - run += v; - (from, run) - } - }) - .collect() - } - - /// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals: - /// the bounds and unit the chart fixes, else Office's automatic ones ([`axis_scale`](fn@axis_scale)). - /// `None` when no series uses the axis. - pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> { - let (lo, hi) = self.plotted_extent(secondary)?; - let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) }; - let full_percent = !secondary && self.kind.percent(); - Some(axis_scale(lo, hi, fmin, fmax, major, max_intervals, !full_percent)) - } - - /// A logarithmic value axis' scale in exponents — (min, max, unit), so its labels are - /// `base^min`, `base^(min+unit)`… — covering the positive values: whole powers, as PowerPoint - /// and LibreOffice draw them. `None` when the axis isn't logarithmic or nothing is positive. - pub fn log_axis_scale(&self, max_intervals: usize) -> Option<(f64, f64, f64)> { - let b = self.y_log_base.filter(|b| b.is_finite() && *b >= 2.0)?; - let vals = self.series.iter().filter(|s| !self.on_secondary(s)).flat_map(|s| s.values.iter().flatten().copied()); - let (lo, hi) = vals - .filter(|v| v.is_finite() && *v > 0.0) - .fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, z)| (a.min(v), z.max(v)))))?; - let e = |v: f64| v.log(b); - let lo = self.y_min.filter(|v| *v > 0.0).map_or(e(lo).floor(), e); - let hi = self.y_max.filter(|v| *v > 0.0).map_or(e(hi).ceil(), e).max(lo + 1.0); - let unit = ((hi - lo) / max_intervals.max(1) as f64).ceil().max(1.0); - Some((lo, lo + ((hi - lo) / unit).ceil() * unit, unit)) - } - - /// A value axis label in the axis' number format and the chart's language. - pub fn axis_label(&self, secondary: bool, v: f64) -> String { - let code = if secondary { &self.y2_format } else { &self.y_format }; - // A 100 % stacked chart's value axis counts in percent unless the file says otherwise. - let code = match code.as_deref() { - None | Some("General") if !secondary && self.kind.percent() => "0%", - c => c.unwrap_or("General"), - }; - crate::numfmt::format(code, v, self.lang.as_deref()) - } - - /// A data label of series `s`: its number format, else its axis', in the chart's language. - pub fn data_label(&self, s: &Series, v: f64) -> String { - let axis = if self.on_secondary(s) { &self.y2_format } else { &self.y_format }; - let code = s.number_format.as_ref().or(axis.as_ref()); - crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref()) - } - - /// Value range across series (stacked sums for stacked types). - pub fn value_range(&self) -> (f64, f64) { - let mut lo: f64 = 0.0; - let mut hi: f64 = 0.0; - if self.kind == ChartType::Waterfall { - for (a, b) in self.waterfall_bars() { - lo = lo.min(a.min(b)); - hi = hi.max(a.max(b)); - } - return (lo, hi); - } - let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); - if self.kind.stacked() { - for i in 0..n { - let (mut p, mut m) = (0.0, 0.0); - for s in &self.series { - let v = s.values.get(i).copied().flatten().unwrap_or(0.0); - if v >= 0.0 { p += v } else { m += v } - } - hi = hi.max(p); - lo = lo.min(m); - } - } else { - for s in &self.series { - for v in s.values.iter().flatten() { - if v.is_finite() { - hi = hi.max(*v); - lo = lo.min(*v); - } - } - } - } - if self.kind.percent() { - return (0.0, 1.0); - } - if !self.kind.stacked() { - for s in &self.series { - for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { - hi = hi.max(y); - lo = lo.min(y); - } - } - } - (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) - } -} - -/// The (min, max, major unit) of an axis over the data range [lo, hi]: `fmin`/`fmax`/`major` -/// where the chart fixes them; otherwise Office's automatic limits, rounded out to the smallest -/// 1-2-5 unit giving at most `max_intervals` intervals: -/// -/// - **Maximum:** `hi + (hi − lo)/20` (with `headroom`), rounded up to the unit. -/// - **Minimum:** zero when every value is ≥ 0 and `lo` is below 5/6 of `hi`; otherwise -/// `lo − (hi − lo)/20`, rounded down to the unit, so values bunched high get an axis that starts -/// near them. -/// - All-negative values mirror this: the axis ends at zero when `hi` is above 5/6 of `lo`. -/// Mixed signs get headroom at both ends. -pub fn axis_scale( - lo: f64, - hi: f64, - fmin: Option, - fmax: Option, - major: Option, - max_intervals: usize, - headroom: bool, -) -> (f64, f64, f64) { - // Values past ±1e100 aren't axis values; clamping keeps the arithmetic finite. - let fin = |v: Option| v.filter(|v| v.is_finite()).map(|v| v.clamp(-1e100, 1e100)); - let (fmin, fmax) = (fin(fmin), fin(fmax)); - let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi).clamp(-1e100, 1e100), lo.max(hi).clamp(-1e100, 1e100)) } else { (0.0, 1.0) }; - // Office's automatic limits ("How chart axis limits are determined", as Jon Peltier - // summarises it): 5 % of the data range past the data, and zero when the values don't - // sit within the top sixth of their largest magnitude. A single value is measured from zero. - let flat = hi - lo < 1e-12; - let pad = if !headroom { - 0.0 - } else if flat { - hi.abs() / 20.0 - } else { - (hi - lo) / 20.0 - }; - let auto_lo = if lo >= 0.0 && (flat || lo < hi * 5.0 / 6.0) { 0.0 } else { lo - pad }; - let auto_hi = if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) { 0.0 } else { hi + pad }; - let mut lo = fmin.unwrap_or(auto_lo); - let mut hi = fmax.unwrap_or(auto_hi); - if hi - lo < 1e-12 { - // A fixed limit on the far side of the data: the automatic end gives way. - match (fmin, fmax) { - (None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(1.0) }, - (Some(_), None) if lo < 0.0 => hi = 0.0, - _ => hi = lo + lo.abs().max(1.0), - } - } - let n = max_intervals.clamp(1, 50) as f64; - let step = match fin(major).filter(|m| *m > 0.0 && (hi - lo) / m <= 1000.0) { - Some(m) => m, - None => { - let mut step = 10f64.powf(((hi - lo) / n).log10().floor()); - let mut k = 0; - // 1, 2, 5, 10, 20… × the magnitude until the intervals fit. - while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 { - step *= if matches!(k % 3, 1) { 2.5 } else { 2.0 }; - k += 1; - } - step - } - }; - let min = fmin.unwrap_or((lo / step + 1e-9).floor() * step); - let max = fmax.unwrap_or((hi / step - 1e-9).ceil() * step).max(min + step); - (min, max, step) -} - -/// "Nice" axis scale: (min, max, major step) covering [lo, hi]. -pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { - let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) }; - let span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo }; - let raw = span / 5.0; - let mag = 10f64.powf(raw.log10().floor()); - let norm = raw / mag; - let step = if norm <= 1.0 { - 1.0 - } else if norm <= 2.0 { - 2.0 - } else if norm <= 2.5 { - 2.5 - } else if norm <= 5.0 { - 5.0 - } else { - 10.0 - } * mag; - let min = (lo / step).floor() * step; - let max = (hi / step).ceil() * step; - let max = if (max - min).abs() < 1e-12 { min + step } else { max }; - (min, max, step) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn log_axes_run_over_whole_powers() { - let mut c = Chart::sample(ChartType::Line); - c.series.truncate(1); - c.series[0].values = vec![Some(1.0), Some(10.0), Some(1000.0), Some(50000.0)]; - assert_eq!(c.log_axis_scale(8), None); - c.y_log_base = Some(10.0); - assert_eq!(c.log_axis_scale(8), Some((0.0, 5.0, 1.0))); - assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0))); - } - - #[test] - fn error_bar_ranges() { - let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)]; - let mut e = ErrorBars::default(); - assert_eq!(e.range(&vals, 0, 3.0), Some((2.0, 4.0))); - e.val_type = "percentage".into(); - e.value = 20.0; - assert_eq!(e.range(&vals, 1, 5.0), Some((4.0, 6.0))); - e.bar_type = "plus".into(); - assert_eq!(e.range(&vals, 1, 5.0), Some((5.0, 6.0))); - e = ErrorBars { val_type: "cust".into(), plus: vec![Some(0.5)], minus: vec![Some(1.5)], ..Default::default() }; - assert_eq!(e.range(&vals, 0, 3.0), Some((1.5, 3.5))); - e = ErrorBars { val_type: "stdDev".into(), ..Default::default() }; - let (lo, hi) = e.range(&vals, 0, 3.0).unwrap(); - assert!((lo + hi - 7.0).abs() < 1e-9 && (hi - lo - 2.0 * 1.2909944487358056).abs() < 1e-9); - } - - #[test] - fn nice_scale_covers() { - let (a, b, s) = nice_scale(0.0, 5.1); - assert!(a <= 0.0 && b >= 5.1 && s > 0.0); - let (a, b, _) = nice_scale(f64::NAN, 3.0); - assert!(a.is_finite() && b.is_finite()); - let (a, b, _) = nice_scale(7.0, 7.0); - assert!(b > a); - } - - #[test] - fn automatic_axis_scales_like_powerpoint() { - // Weekly spend up to ~715 000 on a plot with room for 9 intervals: 0–800 000 by 100 000. - assert_eq!(axis_scale(0.0, 715_000.0, None, None, None, 9, true), (0.0, 800_000.0, 100_000.0)); - // Clicks up to 6 600: 0–7 000 by 1 000. - assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 9, true), (0.0, 7_000.0, 1_000.0)); - // Fewer intervals fit on a short axis. - assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 4, true), (0.0, 8_000.0, 2_000.0)); - // Negative data gets room below; fixed bounds and unit are kept. - let (min, max, _) = axis_scale(-30.0, 100.0, None, None, None, 8, true); - assert!(min <= -30.0 && max >= 105.0); - assert_eq!(axis_scale(0.0, 715_000.0, Some(100_000.0), Some(750_000.0), Some(50_000.0), 9, true), (100_000.0, 750_000.0, 50_000.0)); - // 100 % stacked stays at 0–1 without headroom. - assert_eq!(axis_scale(0.0, 1.0, None, None, None, 5, false), (0.0, 1.0, 0.2)); - // Nonsense never loops or divides by zero. - for (lo, hi) in [(f64::NAN, 1.0), (5.0, 5.0), (0.0, 0.0), (-1e300, 1e300)] { - let (a, b, s) = axis_scale(lo, hi, None, None, Some(0.0), 0, true); - assert!(a.is_finite() && b.is_finite() && s > 0.0 && b > a, "{lo} {hi}: {a} {b} {s}"); - } - } - - fn column(values: &[f64]) -> Chart { - let series = Series { name: "Sales".into(), values: values.iter().map(|v| Some(*v)).collect(), ..Default::default() }; - Chart { categories: (0..values.len()).map(|i| format!("C{i}")).collect(), series: vec![series], ..Default::default() } - } - - /// The primary axis of a chart with room for ten intervals. - fn scale(c: &Chart) -> (f64, f64, f64) { - c.axis_scale(false, 10).unwrap_or_default() - } - - /// Office's documented automatic limits (#144), with room for ten intervals. - #[test] - fn automatic_axis_matches_office() { - // #144: 42.1 / 18.7 / 6.3 → 0–45 in steps of 5, not 0–50 in steps of 10. - assert_eq!(scale(&column(&[42.1, 18.7, 6.3])), (0.0, 45.0, 5.0)); - // PowerPoint's default chart data (max 5): 0–6 in steps of 1. - let mut c = column(&[4.3, 2.5, 3.5, 4.5]); - c.series.push(Series { values: [2.4, 4.4, 1.8, 2.8].map(Some).to_vec(), ..Default::default() }); - c.series.push(Series { values: [2.0, 2.0, 3.0, 5.0].map(Some).to_vec(), ..Default::default() }); - assert_eq!(scale(&c), (0.0, 6.0, 1.0)); - // A maximum of 100 (or 10) gets 5 % headroom, which rounds up to the next major unit. - assert_eq!(scale(&column(&[100.0, 40.0])), (0.0, 120.0, 20.0)); - assert_eq!(scale(&column(&[10.0, 3.0])), (0.0, 12.0, 2.0)); - // Peltier's example: 2.95 + 2.95/20 ≈ 3.1 rounds up to 3.5. - assert_eq!(scale(&column(&[2.95, 0.0])), (0.0, 3.5, 0.5)); - } - - #[test] - fn automatic_axis_minimum() { - // Below 5/6 of the maximum the axis starts at zero; at or above it, a unit below - // min − (max − min)/20. - assert_eq!(scale(&column(&[59.0, 49.0])).0, 0.0); - assert_eq!(scale(&column(&[59.0, 50.0])), (48.0, 60.0, 2.0)); - assert_eq!(scale(&column(&[85.0, 92.0, 97.0])), (84.0, 98.0, 2.0)); - // All-negative data mirrors it: the axis ends at zero, or near the data. - assert_eq!(scale(&column(&[-42.1, -18.7, -6.3])), (-45.0, 0.0, 5.0)); - assert_eq!(scale(&column(&[-59.0, -49.0])).1, 0.0); - assert_eq!(scale(&column(&[-59.0, -50.0])), (-60.0, -48.0, 2.0)); - // Mixed signs get headroom at both ends. - assert_eq!(scale(&column(&[42.1, -18.7])), (-30.0, 50.0, 10.0)); - // Stacked bars build up from zero, so their axis keeps it. - let mut s = column(&[85.0, 92.0]); - s.kind = ChartType::StackedColumn; - assert_eq!(scale(&s).0, 0.0); - // A combo chart's secondary axis follows the same rule. - let mut c = Chart::sample(ChartType::Combo); - c.series.truncate(2); - c.series[0].values = vec![Some(200_000.0), Some(310_000.0)]; - c.series[1].kind = Some(ChartType::Line); - c.series[1].values = vec![Some(7_000.0), Some(7_400.0)]; - c.series[1].secondary_axis = true; - assert_eq!(c.axis_scale(false, 10), Some((0.0, 350_000.0, 50_000.0))); - assert_eq!(c.axis_scale(true, 10), Some((6_950.0, 7_450.0, 50.0))); - } - - #[test] - fn fixed_axis_limits_are_kept() { - let mut c = column(&[42.1, 18.7, 6.3]); - c.y_max = Some(50.0); - assert_eq!(scale(&c), (0.0, 50.0, 5.0)); - c.y_min = Some(10.0); - c.y_max = None; - assert_eq!(scale(&c), (10.0, 45.0, 5.0)); - // A fixed limit beyond the data: the automatic end gives way to it. - let mut c = column(&[85.0, 92.0, 97.0]); - c.y_max = Some(50.0); - assert_eq!(scale(&c), (0.0, 50.0, 5.0)); - let mut c = column(&[-50.0, -48.0]); - c.y_min = Some(-10.0); - assert_eq!(scale(&c), (-10.0, 0.0, 1.0)); - } - - #[test] - fn percent_axis_is_zero_to_one() { - let mut p = column(&[42.1, 18.7, 6.3]); - p.kind = ChartType::PercentColumn; - assert_eq!(scale(&p), (0.0, 1.0, 0.1)); - assert_eq!(p.axis_scale(false, 5), Some((0.0, 1.0, 0.2))); - } - - #[test] - fn degenerate_axes_are_finite() { - for (a, b) in [(0.0, 0.0), (7.0, 7.0), (-7.0, -7.0), (f64::NAN, 3.0), (f64::INFINITY, 1.0), (-f64::MAX, f64::MAX), (0.0, 1e-320)] { - let (min, max, step) = scale(&column(&[a, b])); - assert!(min.is_finite() && max > min && step > 0.0, "{a} {b}: {min} {max} {step}"); - } - let (min, max, _) = scale(&column(&[7.0, 7.0])); - assert!(min == 0.0 && max > 7.0); - assert_eq!(scale(&column(&[])), (0.0, 1.0, 0.1)); - for (fmin, fmax) in [(Some(5.0), Some(5.0)), (Some(5.0), Some(1.0)), (Some(f64::NAN), Some(f64::INFINITY)), (Some(-1e300), None)] { - let (a, b, s) = axis_scale(0.0, 1.0, fmin, fmax, None, 10, true); - assert!(a.is_finite() && b.is_finite() && b > a && s > 0.0, "{fmin:?} {fmax:?}: {a} {b} {s}"); - } - } - - #[test] - fn general_labels_keep_every_digit_the_data_has() { - // #324: labels were rounded to one decimal ("100000.0" for 99999.99). - for kind in [ChartType::Column, ChartType::Pie, ChartType::Doughnut, ChartType::Line, ChartType::Scatter] { - let c = Chart::sample(kind); - let s = &c.series[0]; - for (v, want) in - [(1220.82, "1220.82"), (99999.99, "99999.99"), (1500.05, "1500.05"), (1500.0, "1500"), (1234.567, "1234.567"), (999.99, "999.99")] - { - assert_eq!(c.data_label(s, v), want, "{kind:?}"); - } - } - // Small ranges keep distinct value-axis ticks (0.123 used to give 0, 0.0, 0.1, 0.1…). - let c = Chart::sample(ChartType::Column); - let ticks: Vec = (0..=5).map(|i| c.axis_label(false, f64::from(i) * 0.025)).collect(); - assert_eq!(ticks, ["0", "0.025", "0.05", "0.075", "0.1", "0.125"]); - } - - #[test] - fn a_forecast_grows_the_value_axis() { - let mut c = Chart::sample(ChartType::Line); - c.series.truncate(1); - c.series[0].values = vec![Some(3.0), Some(5.0), Some(7.0), Some(9.0)]; - let before = c.value_range().1; - c.series[0].trendlines.push(Trendline { forward: Some(1.0), ..Default::default() }); - let after = c.value_range().1; - assert!(after > before && after >= 11.0 - 1e-6, "{before} {after}"); - } - - #[test] - fn axis_labels_use_the_format_and_language() { - let mut c = Chart::sample(ChartType::Column); - c.y_format = Some(r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#.into()); - c.lang = Some("ru-RU".into()); - assert_eq!(c.axis_label(false, 800_000.0), "800\u{a0}000 ₽"); - assert_eq!(c.axis_label(false, 0.0), "- ₽"); - // No format: General. - assert_eq!(c.axis_label(true, 2.5), "2,5"); - c.lang = None; - assert_eq!(c.axis_label(true, 2.5), "2.5"); - // Data labels: the series' format, else the axis'. - c.lang = Some("ru-RU".into()); - c.series[0].number_format = Some(r#"#,##0\ "₽""#.into()); - assert_eq!(c.data_label(&c.series[0], 516_600_000.0), "516\u{a0}600\u{a0}000 ₽"); - assert_eq!(c.data_label(&c.series[1], 0.0), "- ₽"); - } - - #[test] - fn full_percent_axes_count_in_percent() { - let mut c = Chart::sample(ChartType::PercentColumn); - assert_eq!(c.axis_label(false, 0.5), "50%"); - c.y_format = Some("0.0%".into()); - assert_eq!(c.axis_label(false, 0.5), "50.0%"); - let c = Chart::sample(ChartType::Column); - assert_eq!(c.axis_label(false, 0.5), "0.5"); - } - - #[test] - fn combo_axis_ranges() { - // Spend in columns on the primary axis, clicks as a line on the secondary one. - let mut c = Chart::sample(ChartType::Combo); - c.series.truncate(2); - c.series[0].kind = Some(ChartType::Column); - c.series[0].values = vec![Some(200_000.0), Some(310_000.0)]; - c.series[1].kind = Some(ChartType::Line); - c.series[1].values = vec![Some(6_000.0), Some(7_400.0)]; - c.series[1].secondary_axis = true; - assert_eq!(c.axis_range(false), Some((0.0, 310_000.0))); - assert_eq!(c.axis_range(true), Some((0.0, 7_400.0))); - assert_eq!(c.series_kind(&c.series[1]), ChartType::Line); - // Without a secondary series there is no secondary axis; a non-combo chart never has one. - c.series[1].secondary_axis = false; - assert_eq!(c.axis_range(true), None); - let col = Chart::sample(ChartType::Column); - assert_eq!(col.axis_range(false), Some(col.value_range())); - assert_eq!(col.axis_range(true), None); - assert!(!col.on_secondary(&col.series[0])); - } - - #[test] - fn sample_ranges() { - let c = Chart::sample(ChartType::StackedColumn); - let (_, hi) = c.value_range(); - assert!(hi > 10.0); - assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1); - } -} - -/// One histogram bin: `[lo, hi]` (the first bin is closed on both sides, later ones open on the -/// left, as Excel bins) and how many observations fall in it. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Bin { - pub lo: f64, - pub hi: f64, - pub count: usize, -} - -/// Bins a histogram has at most (a hostile bin size cannot make millions). -pub const MAX_BINS: usize = 1000; - -/// A box and whisker summary of one group: quartiles by Excel's exclusive method, whiskers at -/// the furthest points within 1.5 IQR of the box, and the points beyond them. -#[derive(Clone, Debug, PartialEq)] -pub struct BoxStats { - pub low: f64, - pub q1: f64, - pub median: f64, - pub q3: f64, - pub high: f64, - pub mean: f64, - pub outliers: Vec, -} - -impl Chart { - /// The first series' finite observations. - fn observations(&self) -> Vec { - self.series.first().map(|s| s.values.iter().flatten().copied().filter(|v| v.is_finite()).collect()).unwrap_or_default() - } - - /// The histogram's bins over the first series: by `bin_size`, else `bin_count`, else - /// Scott's rule (3.49·σ·n^(-1/3)). Empty without observations. - pub fn histogram_bins(&self) -> Vec { - let mut v = self.observations(); - if v.is_empty() { - return vec![]; - } - v.sort_by(f64::total_cmp); - let (lo, hi) = (v[0], v[v.len() - 1]); - let span = hi - lo; - let n = v.len() as f64; - let width = if let Some(w) = self.bin_size.filter(|w| w.is_finite() && *w > 0.0) { - w - } else if let Some(k) = self.bin_count.filter(|k| *k > 0) { - span / f64::from(k) - } else { - let mean = v.iter().sum::() / n; - let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0).max(1.0)).sqrt(); - 3.49 * sd / n.cbrt() - }; - let count = if span <= 0.0 || !width.is_finite() || width <= 0.0 { 1 } else { ((span / width).ceil() as usize).clamp(1, MAX_BINS) }; - let width = if count == 1 && (span <= 0.0 || !width.is_finite() || width <= 0.0) { span.max(1.0) } else { width.max(span / MAX_BINS as f64) }; - let mut bins: Vec = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect(); - for x in v { - // (lo, hi] bins, the first one also holding the minimum. - let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1); - if let Some(b) = bins.get_mut(i) { - b.count += 1; - } - } - bins - } - - /// A histogram (or Pareto) as the chart that draws it: a column chart of the bin counts with - /// touching columns, labelled by bin range. A Pareto sorts the bins by count and adds the - /// cumulative percentage as a line on a 0–100 % secondary axis. - pub fn histogram_view(&self) -> Chart { - let mut bins = self.histogram_bins(); - let pareto = self.kind == ChartType::Pareto; - if pareto { - bins.sort_by_key(|b| std::cmp::Reverse(b.count)); - } - let fmt = |v: f64| { - let r = (v * 100.0).round() / 100.0; - if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } - }; - let first_lo = bins.iter().map(|b| b.lo).fold(f64::INFINITY, f64::min); - let mut v = Chart { - kind: if pareto { ChartType::Combo } else { ChartType::Column }, - categories: bins - .iter() - .map(|b| if b.lo <= first_lo { format!("[{}, {}]", fmt(b.lo), fmt(b.hi)) } else { format!("({}, {}]", fmt(b.lo), fmt(b.hi)) }) - .collect(), - series: vec![], - gap_width: 0.0, - raw: None, - subtotals: vec![], - ..self.clone() - }; - let name = self.series.first().map(|s| s.name.clone()).unwrap_or_default(); - let base = self.series.first().cloned().unwrap_or_default(); - v.series.push(Series { - name, - values: bins.iter().map(|b| Some(b.count as f64)).collect(), - kind: pareto.then_some(ChartType::Column), - number_format: None, - ..base - }); - if pareto { - let total = bins.iter().map(|b| b.count).sum::().max(1) as f64; - let mut run = 0.0; - v.series.push(Series { - name: "Cumulative %".into(), - values: bins - .iter() - .map(|b| { - run += b.count as f64; - Some(run / total) - }) - .collect(), - kind: Some(ChartType::Line), - secondary_axis: true, - ..Default::default() - }); - v.y2_min = Some(0.0); - v.y2_max = Some(1.0); - v.y2_major = Some(0.2); - v.y2_format = Some("0%".into()); - } - v.vary_colors = false; - v - } - - /// The box chart's groups (distinct category labels in order of appearance; one unnamed - /// group without categories) and, per series, each group's summary (None when it has no - /// points). - pub fn box_groups(&self) -> (Vec, Vec>>) { - let mut groups: Vec = Vec::new(); - for c in &self.categories { - if !groups.contains(c) { - groups.push(c.clone()); - } - } - if groups.is_empty() { - groups.push(String::new()); - } - let stats = self - .series - .iter() - .map(|s| { - groups - .iter() - .map(|g| { - let pts: Vec = s - .values - .iter() - .enumerate() - .filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g)) - .filter_map(|(_, v)| *v) - .filter(|v| v.is_finite()) - .collect(); - box_stats_by(pts, self.inclusive_quartiles) - }) - .collect() - }) - .collect(); - (groups, stats) - } -} - -/// Excel's QUARTILE.EXC position `p·(n+1)` (1-based) on sorted data, clamped to the ends. -fn quantile_exc(v: &[f64], p: f64) -> f64 { - let n = v.len(); - let pos = (p * (n as f64 + 1.0)).clamp(1.0, n as f64); - let i = pos.floor() as usize; - let frac = pos - i as f64; - let a = v[i.saturating_sub(1).min(n - 1)]; - let b = v[i.min(n - 1)]; - a + (b - a) * frac -} - -/// Excel's QUARTILE.INC position `p·(n-1)` (0-based) on sorted data. -fn quantile_inc(v: &[f64], p: f64) -> f64 { - let n = v.len(); - let pos = (p * (n as f64 - 1.0)).clamp(0.0, (n - 1) as f64); - let i = pos.floor() as usize; - let a = v[i.min(n - 1)]; - let b = v[(i + 1).min(n - 1)]; - a + (b - a) * (pos - i as f64) -} - -/// The summary of one group's points, with exclusive quartiles. -pub fn box_stats(pts: Vec) -> Option { - box_stats_by(pts, false) -} - -/// The summary of one group's points; `inclusive` picks QUARTILE.INC over QUARTILE.EXC. -pub fn box_stats_by(mut pts: Vec, inclusive: bool) -> Option { - if pts.is_empty() { - return None; - } - pts.sort_by(f64::total_cmp); - let q = |p: f64| if inclusive { quantile_inc(&pts, p) } else { quantile_exc(&pts, p) }; - let (q1, median, q3) = (q(0.25), q(0.5), q(0.75)); - let iqr = q3 - q1; - let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); - let inside: Vec = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect(); - let low = inside.first().copied().unwrap_or(q1).min(q1); - let high = inside.last().copied().unwrap_or(q3).max(q3); - let mean = pts.iter().sum::() / pts.len() as f64; - let outliers = pts.iter().copied().filter(|v| *v < lo_fence || *v > hi_fence).collect(); - Some(BoxStats { low, q1, median, q3, high, mean, outliers }) -} - -/// A node of a treemap or sunburst hierarchy: its label, the total of the leaves under it, and -/// its children (none for a leaf, which names its category index). -#[derive(Clone, Debug, PartialEq)] -pub struct TreeNode { - pub name: String, - pub value: f64, - pub leaf: Option, - pub children: Vec, -} - -/// Levels of hierarchy a chart may have (a hostile file cannot nest deeper). -pub const MAX_LEVELS: usize = 16; - -impl Chart { - /// The first series as a hierarchy: top-level nodes, each holding the consecutive - /// categories that share its label, down to the leaves. Without outer levels every - /// category is a top-level leaf. Negative and missing values count as zero. - pub fn category_tree(&self) -> Vec { - let Some(s) = self.series.first() else { return vec![] }; - let n = self.categories.len().max(s.values.len()); - let levels: Vec<&Vec> = self.category_levels.iter().take(MAX_LEVELS).collect(); - // Each leaf's path from the top level down, blanks filled from the row above. - let mut last: Vec = vec![String::new(); levels.len()]; - let paths: Vec> = (0..n) - .map(|i| { - let mut path = Vec::with_capacity(levels.len() + 1); - for (d, lv) in levels.iter().enumerate().rev() { - let l = lv.get(i).map(|x| x.trim()).unwrap_or(""); - if !l.is_empty() { - last[d] = l.to_string(); - // A new outer label starts fresh inner groups. - for inner in last.iter_mut().take(d) { - inner.clear(); - } - } - path.push(last[d].clone()); - } - path.push(self.categories.get(i).cloned().unwrap_or_default()); - path - }) - .collect(); - let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0); - fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec { - let mut out: Vec = Vec::new(); - let mut i = 0; - while i < rows.len() { - let (idx, path) = rows[i]; - if path.len() <= 1 { - out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] }); - i += 1; - continue; - } - let head = &path[0]; - let mut j = i; - while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head { - j += 1; - } - let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect(); - let children = build(&sub, value); - out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children }); - i = j; - } - out - } - let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect(); - build(&rows, &value) - } -} - -#[cfg(test)] -mod chartex_tests { - use super::*; - - fn obs(v: &[f64]) -> Chart { - Chart { - kind: ChartType::Histogram, - series: vec![Series { values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }], - ..Default::default() - } - } - - #[test] - fn histogram_bins_by_count_size_and_scott() { - let mut c = obs(&[1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 9.0]); - c.bin_count = Some(4); - let b = c.histogram_bins(); - assert_eq!(b.len(), 4); - assert_eq!((b[0].lo, b[0].hi), (1.0, 3.0)); - // (lo, hi] bins with the minimum in the first: 1, 2, 2, 3 | 4, 5 | - | 9. - assert_eq!(b.iter().map(|b| b.count).collect::>(), vec![4, 2, 0, 1]); - c.bin_count = None; - c.bin_size = Some(5.0); - assert_eq!(c.histogram_bins().iter().map(|b| b.count).collect::>(), vec![6, 1]); - c.bin_size = None; - let auto = c.histogram_bins(); - assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::() == 7); - // A tiny bin size is capped; one value gives one bin. - c.bin_size = Some(1e-12); - assert!(c.histogram_bins().len() <= MAX_BINS); - assert_eq!(obs(&[3.0]).histogram_bins(), vec![Bin { lo: 3.0, hi: 4.0, count: 1 }]); - assert!(obs(&[]).histogram_bins().is_empty()); - } - - #[test] - fn pareto_view_sorts_bins_and_adds_the_cumulative_line() { - let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]); - c.kind = ChartType::Pareto; - c.bin_count = Some(3); - let v = c.histogram_view(); - assert_eq!(v.kind, ChartType::Combo); - assert_eq!(v.series[0].values, vec![Some(3.0), Some(2.0), Some(1.0)]); - let cum = &v.series[1]; - assert!(cum.secondary_axis && cum.kind == Some(ChartType::Line)); - assert_eq!(cum.values.last(), Some(&Some(1.0))); - assert_eq!(v.categories[0], "(3.67, 6.33]"); - } - - #[test] - fn box_stats_use_exclusive_quartiles_and_fence_outliers() { - let b = box_stats(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]).unwrap(); - // QUARTILE.EXC on 8 points: positions 2.25, 4.5, 6.75. - assert_eq!((b.q1, b.median, b.q3), (2.25, 4.5, 6.75)); - assert_eq!(b.outliers, vec![100.0]); - assert_eq!((b.low, b.high), (1.0, 7.0)); - assert!(box_stats(vec![]).is_none()); - let one = box_stats(vec![5.0]).unwrap(); - assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0)); - } - - #[test] - fn inclusive_quartiles_follow_quartile_inc() { - let b = box_stats_by(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0], true).unwrap(); - // QUARTILE.INC on 8 points: positions 1.75, 3.5, 5.25 (0-based). - assert_eq!((b.q1, b.median, b.q3), (2.75, 4.5, 6.25)); - } - - #[test] - fn category_tree_groups_runs_and_fills_blank_parents() { - let mut c = Chart::sample(ChartType::Treemap); - let t = c.category_tree(); - assert_eq!(t.iter().map(|n| n.name.as_str()).collect::>(), ["Q1", "Q2", "Q3"]); - assert_eq!(t[0].children.len(), 3); - assert_eq!(t[0].children[1].leaf, Some(1)); - assert!((t[0].value - (4.2 + 2.8 + 3.6)).abs() < 1e-9); - // Blank parents continue the label above; two outer levels nest. - c.category_levels = vec![ - vec!["a".into(), "".into(), "b".into(), "".into(), "c".into(), "".into(), "".into(), "".into()], - vec!["H1".into(), "".into(), "".into(), "".into(), "H2".into(), "".into(), "".into(), "".into()], - ]; - let t = c.category_tree(); - assert_eq!(t.len(), 2); - assert_eq!(t[0].children.iter().map(|n| n.name.as_str()).collect::>(), ["a", "b"]); - assert_eq!(t[1].children[0].children.len(), 4); - // No levels: every category is a leaf. - c.category_levels.clear(); - assert!(c.category_tree().iter().all(|n| n.leaf.is_some())); - } - - #[test] - fn box_groups_follow_category_labels() { - let c = Chart::sample(ChartType::BoxWhisker); - let (groups, stats) = c.box_groups(); - assert_eq!(groups, vec!["Group A".to_string(), "Group B".to_string()]); - assert_eq!(stats.len(), 2); - assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some))); - } -} +//! Charts: an embedded data grid plus type and element options. + +use serde::{Deserialize, Serialize}; + +use crate::style::{ColorRef, Fill, Line}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ChartType { + #[default] + Column, + StackedColumn, + PercentColumn, + Bar, + StackedBar, + PercentBar, + Line, + LineMarkers, + StackedLine, + Area, + StackedArea, + Pie, + Doughnut, + Scatter, + Bubble, + Radar, + FilledRadar, + Stock, + Surface, + Histogram, + Pareto, + BoxWhisker, + Waterfall, + Funnel, + Treemap, + Sunburst, + /// A filled (choropleth) map of regions coloured by value, kept and drawn from its data; it + /// is not offered in the chart menus (PowerPoint needs an online map service to make one). + RegionMap, + Combo, +} + +impl ChartType { + /// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus. + pub const OFFICE_2016: [ChartType; 7] = [ + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Histogram, + ChartType::Pareto, + ChartType::BoxWhisker, + ChartType::Treemap, + ChartType::Sunburst, + ]; + + /// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback). + pub fn is_chartex(self) -> bool { + Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap + } + + pub const MAIN: [ChartType; 12] = [ + ChartType::Column, + ChartType::Bar, + ChartType::Line, + ChartType::Area, + ChartType::Pie, + ChartType::Doughnut, + ChartType::Scatter, + ChartType::Radar, + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Histogram, + ChartType::Treemap, + ]; + pub fn label(self) -> &'static str { + match self { + ChartType::Column => "Clustered Column", + ChartType::StackedColumn => "Stacked Column", + ChartType::PercentColumn => "100% Stacked Column", + ChartType::Bar => "Clustered Bar", + ChartType::StackedBar => "Stacked Bar", + ChartType::PercentBar => "100% Stacked Bar", + ChartType::Line => "Line", + ChartType::LineMarkers => "Line with Markers", + ChartType::StackedLine => "Stacked Line", + ChartType::Area => "Area", + ChartType::StackedArea => "Stacked Area", + ChartType::Pie => "Pie", + ChartType::Doughnut => "Doughnut", + ChartType::Scatter => "Scatter", + ChartType::Bubble => "Bubble", + ChartType::Radar => "Radar", + ChartType::FilledRadar => "Filled Radar", + ChartType::Stock => "Stock", + ChartType::Surface => "Surface", + ChartType::Histogram => "Histogram", + ChartType::Pareto => "Pareto", + ChartType::BoxWhisker => "Box and Whisker", + ChartType::Waterfall => "Waterfall", + ChartType::Funnel => "Funnel", + ChartType::Treemap => "Treemap", + ChartType::Sunburst => "Sunburst", + ChartType::RegionMap => "Map", + ChartType::Combo => "Combo", + } + } + pub fn is_bar_like(self) -> bool { + matches!( + self, + ChartType::Column + | ChartType::StackedColumn + | ChartType::PercentColumn + | ChartType::Bar + | ChartType::StackedBar + | ChartType::PercentBar + | ChartType::Histogram + | ChartType::Pareto + | ChartType::Waterfall + ) + } + pub fn horizontal(self) -> bool { + matches!(self, ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel) + } + pub fn stacked(self) -> bool { + matches!( + self, + ChartType::StackedColumn + | ChartType::StackedBar + | ChartType::StackedLine + | ChartType::StackedArea + | ChartType::PercentColumn + | ChartType::PercentBar + ) + } + pub fn percent(self) -> bool { + matches!(self, ChartType::PercentColumn | ChartType::PercentBar) + } +} + +/// `c:trendlineType`: the six trendlines DrawingML charts store. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TrendlineType { + Exp, + #[default] + Linear, + Log, + MovingAvg, + Poly, + Power, +} + +impl TrendlineType { + pub fn xml(self) -> &'static str { + match self { + TrendlineType::Exp => "exp", + TrendlineType::Linear => "linear", + TrendlineType::Log => "log", + TrendlineType::MovingAvg => "movingAvg", + TrendlineType::Poly => "poly", + TrendlineType::Power => "power", + } + } + pub fn from_xml(s: &str) -> Option { + Some(match s { + "exp" => TrendlineType::Exp, + "linear" => TrendlineType::Linear, + "log" => TrendlineType::Log, + "movingAvg" => TrendlineType::MovingAvg, + "poly" => TrendlineType::Poly, + "power" => TrendlineType::Power, + _ => return None, + }) + } +} + +/// One `c:trendline` on a series. Polynomials use `order` (2–6); moving averages use `period`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Trendline { + pub kind: TrendlineType, + /// `c:name`, when the file sets one. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Polynomial order (`c:order`). + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + /// Moving-average period (`c:period`). + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + /// Forecast forward and backward, in category units (`c:forward`, `c:backward`). + #[serde(skip_serializing_if = "Option::is_none")] + pub forward: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub backward: Option, + /// Cross the value axis at this value (`c:intercept`). + #[serde(skip_serializing_if = "Option::is_none")] + pub intercept: Option, + /// Show the equation (`c:dispEq`). + pub disp_eq: bool, + /// Show R² (`c:dispRSqr`). + pub disp_r_sqr: bool, +} + +impl Default for Trendline { + fn default() -> Self { + Trendline { + kind: TrendlineType::Linear, + name: None, + order: None, + period: None, + forward: None, + backward: None, + intercept: None, + disp_eq: false, + disp_r_sqr: false, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Series { + pub name: String, + pub values: Vec>, + /// X values for scatter/bubble. + pub x_values: Vec>, + pub sizes: Vec>, + pub fill: Option, + pub line: Option, + /// Per-point fill overrides (pie slices). + pub point_fills: Vec<(usize, Fill)>, + pub data_labels: Option, + pub smooth: bool, + pub marker: Option, + /// Combo charts: this series' own type. + pub kind: Option, + pub secondary_axis: bool, + /// The number format of the series' data labels (Excel code): the labels' own, else the + /// source data's. + #[serde(skip_serializing_if = "Option::is_none")] + pub number_format: Option, + /// Trendlines (`c:trendline`), in file order. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub trendlines: Vec, + /// `c:errBars` on the values (y bars; a bar chart's run along its value axis). + #[serde(skip_serializing_if = "Option::is_none")] + pub error_bars: Option, +} + +/// Error bars (`c:errBars`): how far each point's bar reaches below and above it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ErrorBars { + /// `both`, `plus` or `minus`. + pub bar_type: String, + /// `fixedVal`, `percentage`, `stdDev`, `stdErr` or `cust`. + pub val_type: String, + pub value: f64, + /// `cust`: the per-point plus and minus amounts. + pub plus: Vec>, + pub minus: Vec>, + pub end_cap: bool, +} + +impl Default for ErrorBars { + fn default() -> Self { + ErrorBars { bar_type: "both".into(), val_type: "fixedVal".into(), value: 1.0, plus: vec![], minus: vec![], end_cap: true } + } +} + +/// Count, mean and sample standard deviation of a series' finite values (for error bars). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SeriesStats { + pub n: f64, + pub mean: f64, + pub sd: f64, +} + +impl SeriesStats { + pub fn of(values: &[Option]) -> Self { + let vals = || values.iter().flatten().copied().filter(|v| v.is_finite()); + let n = vals().count() as f64; + let mean = vals().sum::() / n.max(1.0); + let sd = if n > 1.0 { (vals().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0)).sqrt() } else { 0.0 }; + SeriesStats { n, mean, sd } + } +} + +impl ErrorBars { + /// Where point `i` (value `v`) of a series with `values` has its bar: (low, high) in data units. + /// Standard deviation bars stand around the series' mean, as Excel draws them; the others + /// around the point. `None` for an amount that isn't a finite number. + pub fn range(&self, values: &[Option], i: usize, v: f64) -> Option<(f64, f64)> { + self.range_in(&SeriesStats::of(values), i, v) + } + + /// [`ErrorBars::range`] with the series' statistics worked out once ([`SeriesStats::of`]), so + /// drawing every point of a long series stays linear. + pub fn range_in(&self, stats: &SeriesStats, i: usize, v: f64) -> Option<(f64, f64)> { + let SeriesStats { n, mean, sd } = *stats; + let (centre, minus, plus) = match self.val_type.as_str() { + "percentage" => (v, (v * self.value / 100.0).abs(), (v * self.value / 100.0).abs()), + "stdDev" => (mean, sd * self.value, sd * self.value), + "stdErr" => (v, sd / n.max(1.0).sqrt(), sd / n.max(1.0).sqrt()), + "cust" => (v, self.minus.get(i).copied().flatten().unwrap_or(0.0), self.plus.get(i).copied().flatten().unwrap_or(0.0)), + _ => (v, self.value, self.value), + }; + let (lo, hi) = match self.bar_type.as_str() { + "plus" => (centre, centre + plus), + "minus" => (centre - minus, centre), + _ => (centre - minus, centre + plus), + }; + (lo.is_finite() && hi.is_finite()).then_some((lo, hi)) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Chart { + pub kind: ChartType, + pub title: Option, + pub categories: Vec, + pub series: Vec, + pub legend: Option, + /// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it. + pub legend_overlay: bool, + /// Text size in points of the axis labels and legend (from their `c:txPr`, else the chart's); + /// `None`: the file gives none. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_size: Option, + /// Title text size in points (its rich text or `c:txPr`). + #[serde(skip_serializing_if = "Option::is_none")] + pub title_size: Option, + pub data_labels: bool, + pub gridlines: bool, + /// `c:minorGridlines` on the value axis: five to a major unit, between the major ones. + pub minor_gridlines: bool, + pub axis_titles: (Option, Option), + /// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…). + pub style: u32, + pub palette: String, + pub plot_fill: Option, + pub chart_fill: Option, + pub text_color: Option, + pub hole_size: f64, + pub gap_width: f64, + pub overlap: f64, + pub vary_colors: bool, + pub y_min: Option, + pub y_max: Option, + /// The value axis' major unit, when the chart fixes it. + pub y_major: Option, + /// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000). + pub y_log_base: Option, + /// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`). + pub y_format: Option, + /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. + pub y_hidden: bool, + /// The category axis runs backwards (`c:orientation val="maxMin"`): right to left, or on a + /// horizontal bar chart top to bottom (the default puts the first category at the bottom). + pub cat_reversed: bool, + /// A combo chart's secondary value axis: bounds, major unit and number format. + pub y2_min: Option, + pub y2_max: Option, + pub y2_major: Option, + pub y2_format: Option, + pub y2_hidden: bool, + /// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark. + pub lang: Option, + /// A waterfall's total points (`cx:subtotals`): bars that stand on the axis and restart + /// the running total, instead of floating. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub subtotals: Vec, + /// Outer category levels of a treemap or sunburst, innermost first (the leaves' parents, + /// then theirs…), each with one label per category. A blank label continues the one above it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub category_levels: Vec>, + /// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub inclusive_quartiles: bool, + /// A stock chart's up/down bars (`c:upDownBars`): open-high-low-close, the box between + /// the first and last series, hollow when the price rose. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub up_down_bars: bool, + /// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are + /// automatic (Scott's rule, as Excel does). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_count: Option, + /// A histogram's bin width (`cx:binSize`). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_size: Option, + /// Original chart XML part kept for round-trip of unmodelled options. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, + /// Package parts the original chart still points at (embedded workbook, styles, drawings). + /// The PPTX writer clears this before comparing modelled state. Native saves keep it too. + #[serde(skip_serializing_if = "ChartKept::is_empty")] + pub kept: ChartKept, +} + +/// One relationship of a chart part, with the target string as it was written. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChartRel { + pub id: String, + pub rel_type: String, + pub target: String, + /// Absolute name of the part an internal relationship reaches (matches a `ChartPart::name` + /// up to case); empty for external ones. The writer renames kept parts and uses this to + /// point the relationship at the new name. + pub part: String, + pub external: bool, +} + +/// A part reached from a chart (the workbook, a drawing, an image it uses). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChartPart { + /// Absolute part name (`ppt/embeddings/Workbook.xlsx`). + pub name: String, + pub content_type: String, + /// Runtime bytes; older native files stored these inline in the manifest. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bytes: Vec, + /// Native ZIP member while encoding/decoding. The native reader consumes this reference. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_entry: Option, +} + +/// Sidecar for an unchanged chart: relationships and the parts they reach. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ChartKept { + pub rels: Vec, + pub parts: Vec, +} + +impl ChartKept { + fn is_empty(&self) -> bool { + self.rels.is_empty() && self.parts.is_empty() + } +} + +impl Default for Chart { + fn default() -> Self { + Chart { + kind: ChartType::Column, + title: Some("Chart Title".into()), + categories: vec![], + series: vec![], + legend: Some("b".into()), + legend_overlay: false, + text_size: None, + title_size: None, + data_labels: false, + gridlines: true, + minor_gridlines: false, + axis_titles: (None, None), + style: 2, + palette: "colorful1".into(), + plot_fill: None, + chart_fill: None, + text_color: None, + hole_size: 0.5, + gap_width: 2.19, + overlap: -0.27, + vary_colors: false, + y_min: None, + y_max: None, + y_major: None, + y_log_base: None, + y_format: None, + y_hidden: false, + cat_reversed: false, + y2_min: None, + y2_max: None, + y2_major: None, + y2_format: None, + y2_hidden: false, + lang: None, + subtotals: vec![], + category_levels: vec![], + inclusive_quartiles: false, + up_down_bars: false, + bin_count: None, + bin_size: None, + raw: None, + kept: ChartKept::default(), + } + } +} + +impl Chart { + /// The sample data a new chart starts with (our own numbers). + pub fn sample(kind: ChartType) -> Self { + let cats = ["Q1", "Q2", "Q3", "Q4"]; + let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]]; + let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() }; + if matches!(kind, ChartType::Treemap | ChartType::Sunburst) { + // A two-level hierarchy: months inside quarters. + c.categories = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(|s| s.to_string()).collect(); + c.category_levels = vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(|s| s.to_string()).collect()]; + let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0]; + c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); + c.vary_colors = true; + return c; + } + if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { + // These plot raw observations: a histogram bins one series, a box chart summarises + // each category's points. + let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0]; + let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 }; + c.categories = if kind == ChartType::BoxWhisker { + (0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect() + } else { + vec![] + }; + for s in 0..n_series { + c.series.push(Series { + name: format!("Series {}", s + 1), + values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(), + ..Default::default() + }); + } + c.gridlines = true; + c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string()); + return c; + } + if kind == ChartType::Stock { + // High-low-close over five days, the series in the order PowerPoint expects. + c.categories = ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(|s| s.to_string()).collect(); + let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])]; + for (name, v) in hlc { + c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); + } + return c; + } + let n_series = + if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 }; + for s in 0..n_series { + c.series.push(Series { + name: format!("Series {}", s + 1), + values: data.iter().map(|row| row.get(s).copied()).collect(), + x_values: if kind == ChartType::Scatter { (1..=4).map(|x| Some(x as f64)).collect() } else { vec![] }, + ..Default::default() + }); + } + c.vary_colors = n_series == 1; + if kind == ChartType::Combo { + // Columns with the last series as a line, as PowerPoint's combo starts. + for (i, s) in c.series.iter_mut().enumerate() { + s.kind = Some(if i + 1 == n_series { ChartType::Line } else { ChartType::Column }); + } + } + c + } + /// A series' own chart type: its own in a combo chart (columns when it names none), else the + /// chart's. + pub fn series_kind(&self, s: &Series) -> ChartType { + match (self.kind, s.kind) { + (ChartType::Combo, Some(k)) if k != ChartType::Combo => k, + (ChartType::Combo, _) => ChartType::Column, + (k, _) => k, + } + } + + /// Whether a series is plotted against the secondary value axis (combo charts only). + pub fn on_secondary(&self, s: &Series) -> bool { + self.kind == ChartType::Combo && s.secondary_axis + } + + /// The value range of one value axis: the primary axis (`y_min`/`y_max` apply), or a combo + /// chart's secondary axis. A combo chart stacks only its stacked series. `None` when no series + /// uses that axis. + pub fn axis_range(&self, secondary: bool) -> Option<(f64, f64)> { + if self.kind != ChartType::Combo { + return (!secondary).then(|| self.value_range()); + } + let (lo, hi) = self.plotted_extent(secondary)?; + let (lo, hi) = (lo.min(0.0), hi.max(0.0)); + Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) }) + } + + /// The smallest and largest value plotted against one value axis: plain values as they are, + /// stacked series as running sums from zero, 100 % series as 0–1. `(0, 0)` without finite + /// values; `None` when no series uses the axis. + fn plotted_extent(&self, secondary: bool) -> Option<(f64, f64)> { + if self.kind == ChartType::Waterfall { + return (!secondary).then(|| self.value_range()); + } + let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect(); + if ser.is_empty() { + return (!secondary && self.kind != ChartType::Combo).then_some((0.0, 0.0)); + } + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0); + let mut percent = false; + for i in 0..n { + let (mut p, mut m, mut stacked) = (0.0, 0.0, false); + for s in &ser { + let k = self.series_kind(s); + let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + if k.percent() { + percent = true; + } else if k.stacked() { + stacked = true; + if v >= 0.0 { p += v } else { m += v } + } else { + hi = hi.max(v); + lo = lo.min(v); + } + } + if stacked { + hi = hi.max(p); + lo = lo.min(m); + } + } + if !percent { + for s in &ser { + if self.series_kind(s).stacked() { + continue; + } + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } + } + if percent { + lo = lo.min(0.0); + hi = hi.max(1.0); + } + Some(if lo <= hi { (lo, hi) } else { (0.0, 0.0) }) + } + + /// A waterfall's bars as (from, to) on the value axis: each point floats from the running + /// total, a total point stands on zero and sets it. + pub fn waterfall_bars(&self) -> Vec<(f64, f64)> { + let Some(s) = self.series.first() else { return vec![] }; + let mut run = 0.0; + s.values + .iter() + .enumerate() + .map(|(i, v)| { + let v = v.unwrap_or(0.0); + if self.subtotals.contains(&i) { + run = v; + (0.0, v) + } else { + let from = run; + run += v; + (from, run) + } + }) + .collect() + } + + /// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals: + /// the bounds and unit the chart fixes, else Office's automatic ones ([`axis_scale`](fn@axis_scale)). + /// `None` when no series uses the axis. + pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> { + let (lo, hi) = self.plotted_extent(secondary)?; + let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) }; + let full_percent = !secondary && self.kind.percent(); + Some(axis_scale(lo, hi, fmin, fmax, major, max_intervals, !full_percent)) + } + + /// A logarithmic value axis' scale in exponents — (min, max, unit), so its labels are + /// `base^min`, `base^(min+unit)`… — covering the positive values: whole powers, as PowerPoint + /// and LibreOffice draw them. `None` when the axis isn't logarithmic or nothing is positive. + pub fn log_axis_scale(&self, max_intervals: usize) -> Option<(f64, f64, f64)> { + let b = self.y_log_base.filter(|b| b.is_finite() && *b >= 2.0)?; + let vals = self.series.iter().filter(|s| !self.on_secondary(s)).flat_map(|s| s.values.iter().flatten().copied()); + let (lo, hi) = vals + .filter(|v| v.is_finite() && *v > 0.0) + .fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, z)| (a.min(v), z.max(v)))))?; + let e = |v: f64| v.log(b); + let lo = self.y_min.filter(|v| *v > 0.0).map_or(e(lo).floor(), e); + let hi = self.y_max.filter(|v| *v > 0.0).map_or(e(hi).ceil(), e).max(lo + 1.0); + let unit = ((hi - lo) / max_intervals.max(1) as f64).ceil().max(1.0); + Some((lo, lo + ((hi - lo) / unit).ceil() * unit, unit)) + } + + /// A value axis label in the axis' number format and the chart's language. + pub fn axis_label(&self, secondary: bool, v: f64) -> String { + let code = if secondary { &self.y2_format } else { &self.y_format }; + // A 100 % stacked chart's value axis counts in percent unless the file says otherwise. + let code = match code.as_deref() { + None | Some("General") if !secondary && self.kind.percent() => "0%", + c => c.unwrap_or("General"), + }; + crate::numfmt::format(code, v, self.lang.as_deref()) + } + + /// A data label of series `s`: its number format, else its axis', in the chart's language. + pub fn data_label(&self, s: &Series, v: f64) -> String { + let axis = if self.on_secondary(s) { &self.y2_format } else { &self.y_format }; + let code = s.number_format.as_ref().or(axis.as_ref()); + crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref()) + } + + /// Value range across series (stacked sums for stacked types). + pub fn value_range(&self) -> (f64, f64) { + let mut lo: f64 = 0.0; + let mut hi: f64 = 0.0; + if self.kind == ChartType::Waterfall { + for (a, b) in self.waterfall_bars() { + lo = lo.min(a.min(b)); + hi = hi.max(a.max(b)); + } + return (lo, hi); + } + let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); + if self.kind.stacked() { + for i in 0..n { + let (mut p, mut m) = (0.0, 0.0); + for s in &self.series { + let v = s.values.get(i).copied().flatten().unwrap_or(0.0); + if v >= 0.0 { p += v } else { m += v } + } + hi = hi.max(p); + lo = lo.min(m); + } + } else { + for s in &self.series { + for v in s.values.iter().flatten() { + if v.is_finite() { + hi = hi.max(*v); + lo = lo.min(*v); + } + } + } + } + if self.kind.percent() { + return (0.0, 1.0); + } + if !self.kind.stacked() { + for s in &self.series { + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } + } + (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) + } +} + +/// The (min, max, major unit) of an axis over the data range [lo, hi]: `fmin`/`fmax`/`major` +/// where the chart fixes them; otherwise Office's automatic limits, rounded out to the smallest +/// 1-2-5 unit giving at most `max_intervals` intervals: +/// +/// - **Maximum:** `hi + (hi − lo)/20` (with `headroom`), rounded up to the unit. +/// - **Minimum:** zero when every value is ≥ 0 and `lo` is below 5/6 of `hi`; otherwise +/// `lo − (hi − lo)/20`, rounded down to the unit, so values bunched high get an axis that starts +/// near them. +/// - All-negative values mirror this: the axis ends at zero when `hi` is above 5/6 of `lo`. +/// Mixed signs get headroom at both ends. +pub fn axis_scale( + lo: f64, + hi: f64, + fmin: Option, + fmax: Option, + major: Option, + max_intervals: usize, + headroom: bool, +) -> (f64, f64, f64) { + // Values past ±1e100 aren't axis values; clamping keeps the arithmetic finite. + let fin = |v: Option| v.filter(|v| v.is_finite()).map(|v| v.clamp(-1e100, 1e100)); + let (fmin, fmax) = (fin(fmin), fin(fmax)); + let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi).clamp(-1e100, 1e100), lo.max(hi).clamp(-1e100, 1e100)) } else { (0.0, 1.0) }; + // Office's automatic limits ("How chart axis limits are determined", as Jon Peltier + // summarises it): 5 % of the data range past the data, and zero when the values don't + // sit within the top sixth of their largest magnitude. A single value is measured from zero. + let flat = hi - lo < 1e-12; + let pad = if !headroom { + 0.0 + } else if flat { + hi.abs() / 20.0 + } else { + (hi - lo) / 20.0 + }; + let auto_lo = if lo >= 0.0 && (flat || lo < hi * 5.0 / 6.0) { 0.0 } else { lo - pad }; + let auto_hi = if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) { 0.0 } else { hi + pad }; + let mut lo = fmin.unwrap_or(auto_lo); + let mut hi = fmax.unwrap_or(auto_hi); + if hi - lo < 1e-12 { + // A fixed limit on the far side of the data: the automatic end gives way. + match (fmin, fmax) { + (None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(1.0) }, + (Some(_), None) if lo < 0.0 => hi = 0.0, + _ => hi = lo + lo.abs().max(1.0), + } + } + let n = max_intervals.clamp(1, 50) as f64; + let step = match fin(major).filter(|m| *m > 0.0 && (hi - lo) / m <= 1000.0) { + Some(m) => m, + None => { + let mut step = 10f64.powf(((hi - lo) / n).log10().floor()); + let mut k = 0; + // 1, 2, 5, 10, 20… × the magnitude until the intervals fit. + while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 { + step *= if matches!(k % 3, 1) { 2.5 } else { 2.0 }; + k += 1; + } + step + } + }; + let min = fmin.unwrap_or((lo / step + 1e-9).floor() * step); + let max = fmax.unwrap_or((hi / step - 1e-9).ceil() * step).max(min + step); + (min, max, step) +} + +/// "Nice" axis scale: (min, max, major step) covering [lo, hi]. +pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { + let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) }; + let span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo }; + let raw = span / 5.0; + let mag = 10f64.powf(raw.log10().floor()); + let norm = raw / mag; + let step = if norm <= 1.0 { + 1.0 + } else if norm <= 2.0 { + 2.0 + } else if norm <= 2.5 { + 2.5 + } else if norm <= 5.0 { + 5.0 + } else { + 10.0 + } * mag; + let min = (lo / step).floor() * step; + let max = (hi / step).ceil() * step; + let max = if (max - min).abs() < 1e-12 { min + step } else { max }; + (min, max, step) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_axes_run_over_whole_powers() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(1.0), Some(10.0), Some(1000.0), Some(50000.0)]; + assert_eq!(c.log_axis_scale(8), None); + c.y_log_base = Some(10.0); + assert_eq!(c.log_axis_scale(8), Some((0.0, 5.0, 1.0))); + assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0))); + } + + #[test] + fn error_bar_ranges() { + let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)]; + let mut e = ErrorBars::default(); + assert_eq!(e.range(&vals, 0, 3.0), Some((2.0, 4.0))); + e.val_type = "percentage".into(); + e.value = 20.0; + assert_eq!(e.range(&vals, 1, 5.0), Some((4.0, 6.0))); + e.bar_type = "plus".into(); + assert_eq!(e.range(&vals, 1, 5.0), Some((5.0, 6.0))); + e = ErrorBars { val_type: "cust".into(), plus: vec![Some(0.5)], minus: vec![Some(1.5)], ..Default::default() }; + assert_eq!(e.range(&vals, 0, 3.0), Some((1.5, 3.5))); + e = ErrorBars { val_type: "stdDev".into(), ..Default::default() }; + let (lo, hi) = e.range(&vals, 0, 3.0).unwrap(); + assert!((lo + hi - 7.0).abs() < 1e-9 && (hi - lo - 2.0 * 1.2909944487358056).abs() < 1e-9); + } + + #[test] + fn nice_scale_covers() { + let (a, b, s) = nice_scale(0.0, 5.1); + assert!(a <= 0.0 && b >= 5.1 && s > 0.0); + let (a, b, _) = nice_scale(f64::NAN, 3.0); + assert!(a.is_finite() && b.is_finite()); + let (a, b, _) = nice_scale(7.0, 7.0); + assert!(b > a); + } + + #[test] + fn automatic_axis_scales_like_powerpoint() { + // Weekly spend up to ~715 000 on a plot with room for 9 intervals: 0–800 000 by 100 000. + assert_eq!(axis_scale(0.0, 715_000.0, None, None, None, 9, true), (0.0, 800_000.0, 100_000.0)); + // Clicks up to 6 600: 0–7 000 by 1 000. + assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 9, true), (0.0, 7_000.0, 1_000.0)); + // Fewer intervals fit on a short axis. + assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 4, true), (0.0, 8_000.0, 2_000.0)); + // Negative data gets room below; fixed bounds and unit are kept. + let (min, max, _) = axis_scale(-30.0, 100.0, None, None, None, 8, true); + assert!(min <= -30.0 && max >= 105.0); + assert_eq!(axis_scale(0.0, 715_000.0, Some(100_000.0), Some(750_000.0), Some(50_000.0), 9, true), (100_000.0, 750_000.0, 50_000.0)); + // 100 % stacked stays at 0–1 without headroom. + assert_eq!(axis_scale(0.0, 1.0, None, None, None, 5, false), (0.0, 1.0, 0.2)); + // Nonsense never loops or divides by zero. + for (lo, hi) in [(f64::NAN, 1.0), (5.0, 5.0), (0.0, 0.0), (-1e300, 1e300)] { + let (a, b, s) = axis_scale(lo, hi, None, None, Some(0.0), 0, true); + assert!(a.is_finite() && b.is_finite() && s > 0.0 && b > a, "{lo} {hi}: {a} {b} {s}"); + } + } + + fn column(values: &[f64]) -> Chart { + let series = Series { name: "Sales".into(), values: values.iter().map(|v| Some(*v)).collect(), ..Default::default() }; + Chart { categories: (0..values.len()).map(|i| format!("C{i}")).collect(), series: vec![series], ..Default::default() } + } + + /// The primary axis of a chart with room for ten intervals. + fn scale(c: &Chart) -> (f64, f64, f64) { + c.axis_scale(false, 10).unwrap_or_default() + } + + /// Office's documented automatic limits (#144), with room for ten intervals. + #[test] + fn automatic_axis_matches_office() { + // #144: 42.1 / 18.7 / 6.3 → 0–45 in steps of 5, not 0–50 in steps of 10. + assert_eq!(scale(&column(&[42.1, 18.7, 6.3])), (0.0, 45.0, 5.0)); + // PowerPoint's default chart data (max 5): 0–6 in steps of 1. + let mut c = column(&[4.3, 2.5, 3.5, 4.5]); + c.series.push(Series { values: [2.4, 4.4, 1.8, 2.8].map(Some).to_vec(), ..Default::default() }); + c.series.push(Series { values: [2.0, 2.0, 3.0, 5.0].map(Some).to_vec(), ..Default::default() }); + assert_eq!(scale(&c), (0.0, 6.0, 1.0)); + // A maximum of 100 (or 10) gets 5 % headroom, which rounds up to the next major unit. + assert_eq!(scale(&column(&[100.0, 40.0])), (0.0, 120.0, 20.0)); + assert_eq!(scale(&column(&[10.0, 3.0])), (0.0, 12.0, 2.0)); + // Peltier's example: 2.95 + 2.95/20 ≈ 3.1 rounds up to 3.5. + assert_eq!(scale(&column(&[2.95, 0.0])), (0.0, 3.5, 0.5)); + } + + #[test] + fn automatic_axis_minimum() { + // Below 5/6 of the maximum the axis starts at zero; at or above it, a unit below + // min − (max − min)/20. + assert_eq!(scale(&column(&[59.0, 49.0])).0, 0.0); + assert_eq!(scale(&column(&[59.0, 50.0])), (48.0, 60.0, 2.0)); + assert_eq!(scale(&column(&[85.0, 92.0, 97.0])), (84.0, 98.0, 2.0)); + // All-negative data mirrors it: the axis ends at zero, or near the data. + assert_eq!(scale(&column(&[-42.1, -18.7, -6.3])), (-45.0, 0.0, 5.0)); + assert_eq!(scale(&column(&[-59.0, -49.0])).1, 0.0); + assert_eq!(scale(&column(&[-59.0, -50.0])), (-60.0, -48.0, 2.0)); + // Mixed signs get headroom at both ends. + assert_eq!(scale(&column(&[42.1, -18.7])), (-30.0, 50.0, 10.0)); + // Stacked bars build up from zero, so their axis keeps it. + let mut s = column(&[85.0, 92.0]); + s.kind = ChartType::StackedColumn; + assert_eq!(scale(&s).0, 0.0); + // A combo chart's secondary axis follows the same rule. + let mut c = Chart::sample(ChartType::Combo); + c.series.truncate(2); + c.series[0].values = vec![Some(200_000.0), Some(310_000.0)]; + c.series[1].kind = Some(ChartType::Line); + c.series[1].values = vec![Some(7_000.0), Some(7_400.0)]; + c.series[1].secondary_axis = true; + assert_eq!(c.axis_scale(false, 10), Some((0.0, 350_000.0, 50_000.0))); + assert_eq!(c.axis_scale(true, 10), Some((6_950.0, 7_450.0, 50.0))); + } + + #[test] + fn fixed_axis_limits_are_kept() { + let mut c = column(&[42.1, 18.7, 6.3]); + c.y_max = Some(50.0); + assert_eq!(scale(&c), (0.0, 50.0, 5.0)); + c.y_min = Some(10.0); + c.y_max = None; + assert_eq!(scale(&c), (10.0, 45.0, 5.0)); + // A fixed limit beyond the data: the automatic end gives way to it. + let mut c = column(&[85.0, 92.0, 97.0]); + c.y_max = Some(50.0); + assert_eq!(scale(&c), (0.0, 50.0, 5.0)); + let mut c = column(&[-50.0, -48.0]); + c.y_min = Some(-10.0); + assert_eq!(scale(&c), (-10.0, 0.0, 1.0)); + } + + #[test] + fn percent_axis_is_zero_to_one() { + let mut p = column(&[42.1, 18.7, 6.3]); + p.kind = ChartType::PercentColumn; + assert_eq!(scale(&p), (0.0, 1.0, 0.1)); + assert_eq!(p.axis_scale(false, 5), Some((0.0, 1.0, 0.2))); + } + + #[test] + fn degenerate_axes_are_finite() { + for (a, b) in [(0.0, 0.0), (7.0, 7.0), (-7.0, -7.0), (f64::NAN, 3.0), (f64::INFINITY, 1.0), (-f64::MAX, f64::MAX), (0.0, 1e-320)] { + let (min, max, step) = scale(&column(&[a, b])); + assert!(min.is_finite() && max > min && step > 0.0, "{a} {b}: {min} {max} {step}"); + } + let (min, max, _) = scale(&column(&[7.0, 7.0])); + assert!(min == 0.0 && max > 7.0); + assert_eq!(scale(&column(&[])), (0.0, 1.0, 0.1)); + for (fmin, fmax) in [(Some(5.0), Some(5.0)), (Some(5.0), Some(1.0)), (Some(f64::NAN), Some(f64::INFINITY)), (Some(-1e300), None)] { + let (a, b, s) = axis_scale(0.0, 1.0, fmin, fmax, None, 10, true); + assert!(a.is_finite() && b.is_finite() && b > a && s > 0.0, "{fmin:?} {fmax:?}: {a} {b} {s}"); + } + } + + #[test] + fn general_labels_keep_every_digit_the_data_has() { + // #324: labels were rounded to one decimal ("100000.0" for 99999.99). + for kind in [ChartType::Column, ChartType::Pie, ChartType::Doughnut, ChartType::Line, ChartType::Scatter] { + let c = Chart::sample(kind); + let s = &c.series[0]; + for (v, want) in + [(1220.82, "1220.82"), (99999.99, "99999.99"), (1500.05, "1500.05"), (1500.0, "1500"), (1234.567, "1234.567"), (999.99, "999.99")] + { + assert_eq!(c.data_label(s, v), want, "{kind:?}"); + } + } + // Small ranges keep distinct value-axis ticks (0.123 used to give 0, 0.0, 0.1, 0.1…). + let c = Chart::sample(ChartType::Column); + let ticks: Vec = (0..=5).map(|i| c.axis_label(false, f64::from(i) * 0.025)).collect(); + assert_eq!(ticks, ["0", "0.025", "0.05", "0.075", "0.1", "0.125"]); + } + + #[test] + fn a_forecast_grows_the_value_axis() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(3.0), Some(5.0), Some(7.0), Some(9.0)]; + let before = c.value_range().1; + c.series[0].trendlines.push(Trendline { forward: Some(1.0), ..Default::default() }); + let after = c.value_range().1; + assert!(after > before && after >= 11.0 - 1e-6, "{before} {after}"); + } + + #[test] + fn axis_labels_use_the_format_and_language() { + let mut c = Chart::sample(ChartType::Column); + c.y_format = Some(r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#.into()); + c.lang = Some("ru-RU".into()); + assert_eq!(c.axis_label(false, 800_000.0), "800\u{a0}000 ₽"); + assert_eq!(c.axis_label(false, 0.0), "- ₽"); + // No format: General. + assert_eq!(c.axis_label(true, 2.5), "2,5"); + c.lang = None; + assert_eq!(c.axis_label(true, 2.5), "2.5"); + // Data labels: the series' format, else the axis'. + c.lang = Some("ru-RU".into()); + c.series[0].number_format = Some(r#"#,##0\ "₽""#.into()); + assert_eq!(c.data_label(&c.series[0], 516_600_000.0), "516\u{a0}600\u{a0}000 ₽"); + assert_eq!(c.data_label(&c.series[1], 0.0), "- ₽"); + } + + #[test] + fn full_percent_axes_count_in_percent() { + let mut c = Chart::sample(ChartType::PercentColumn); + assert_eq!(c.axis_label(false, 0.5), "50%"); + c.y_format = Some("0.0%".into()); + assert_eq!(c.axis_label(false, 0.5), "50.0%"); + let c = Chart::sample(ChartType::Column); + assert_eq!(c.axis_label(false, 0.5), "0.5"); + } + + #[test] + fn combo_axis_ranges() { + // Spend in columns on the primary axis, clicks as a line on the secondary one. + let mut c = Chart::sample(ChartType::Combo); + c.series.truncate(2); + c.series[0].kind = Some(ChartType::Column); + c.series[0].values = vec![Some(200_000.0), Some(310_000.0)]; + c.series[1].kind = Some(ChartType::Line); + c.series[1].values = vec![Some(6_000.0), Some(7_400.0)]; + c.series[1].secondary_axis = true; + assert_eq!(c.axis_range(false), Some((0.0, 310_000.0))); + assert_eq!(c.axis_range(true), Some((0.0, 7_400.0))); + assert_eq!(c.series_kind(&c.series[1]), ChartType::Line); + // Without a secondary series there is no secondary axis; a non-combo chart never has one. + c.series[1].secondary_axis = false; + assert_eq!(c.axis_range(true), None); + let col = Chart::sample(ChartType::Column); + assert_eq!(col.axis_range(false), Some(col.value_range())); + assert_eq!(col.axis_range(true), None); + assert!(!col.on_secondary(&col.series[0])); + } + + #[test] + fn sample_ranges() { + let c = Chart::sample(ChartType::StackedColumn); + let (_, hi) = c.value_range(); + assert!(hi > 10.0); + assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1); + } +} + +/// One histogram bin: `[lo, hi]` (the first bin is closed on both sides, later ones open on the +/// left, as Excel bins) and how many observations fall in it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Bin { + pub lo: f64, + pub hi: f64, + pub count: usize, +} + +/// Bins a histogram has at most (a hostile bin size cannot make millions). +pub const MAX_BINS: usize = 1000; + +/// A box and whisker summary of one group: quartiles by Excel's exclusive method, whiskers at +/// the furthest points within 1.5 IQR of the box, and the points beyond them. +#[derive(Clone, Debug, PartialEq)] +pub struct BoxStats { + pub low: f64, + pub q1: f64, + pub median: f64, + pub q3: f64, + pub high: f64, + pub mean: f64, + pub outliers: Vec, +} + +impl Chart { + /// The first series' finite observations. + fn observations(&self) -> Vec { + self.series.first().map(|s| s.values.iter().flatten().copied().filter(|v| v.is_finite()).collect()).unwrap_or_default() + } + + /// The histogram's bins over the first series: by `bin_size`, else `bin_count`, else + /// Scott's rule (3.49·σ·n^(-1/3)). Empty without observations. + pub fn histogram_bins(&self) -> Vec { + let mut v = self.observations(); + if v.is_empty() { + return vec![]; + } + v.sort_by(f64::total_cmp); + let (lo, hi) = (v[0], v[v.len() - 1]); + let span = hi - lo; + let n = v.len() as f64; + let width = if let Some(w) = self.bin_size.filter(|w| w.is_finite() && *w > 0.0) { + w + } else if let Some(k) = self.bin_count.filter(|k| *k > 0) { + span / f64::from(k) + } else { + let mean = v.iter().sum::() / n; + let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0).max(1.0)).sqrt(); + 3.49 * sd / n.cbrt() + }; + let count = if span <= 0.0 || !width.is_finite() || width <= 0.0 { 1 } else { ((span / width).ceil() as usize).clamp(1, MAX_BINS) }; + let width = if count == 1 && (span <= 0.0 || !width.is_finite() || width <= 0.0) { span.max(1.0) } else { width.max(span / MAX_BINS as f64) }; + let mut bins: Vec = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect(); + for x in v { + // (lo, hi] bins, the first one also holding the minimum. + let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1); + if let Some(b) = bins.get_mut(i) { + b.count += 1; + } + } + bins + } + + /// A histogram (or Pareto) as the chart that draws it: a column chart of the bin counts with + /// touching columns, labelled by bin range. A Pareto sorts the bins by count and adds the + /// cumulative percentage as a line on a 0–100 % secondary axis. + pub fn histogram_view(&self) -> Chart { + let mut bins = self.histogram_bins(); + let pareto = self.kind == ChartType::Pareto; + if pareto { + bins.sort_by_key(|b| std::cmp::Reverse(b.count)); + } + let fmt = |v: f64| { + let r = (v * 100.0).round() / 100.0; + if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } + }; + let first_lo = bins.iter().map(|b| b.lo).fold(f64::INFINITY, f64::min); + let mut v = Chart { + kind: if pareto { ChartType::Combo } else { ChartType::Column }, + categories: bins + .iter() + .map(|b| if b.lo <= first_lo { format!("[{}, {}]", fmt(b.lo), fmt(b.hi)) } else { format!("({}, {}]", fmt(b.lo), fmt(b.hi)) }) + .collect(), + series: vec![], + gap_width: 0.0, + raw: None, + subtotals: vec![], + ..self.clone() + }; + let name = self.series.first().map(|s| s.name.clone()).unwrap_or_default(); + let base = self.series.first().cloned().unwrap_or_default(); + v.series.push(Series { + name, + values: bins.iter().map(|b| Some(b.count as f64)).collect(), + kind: pareto.then_some(ChartType::Column), + number_format: None, + ..base + }); + if pareto { + let total = bins.iter().map(|b| b.count).sum::().max(1) as f64; + let mut run = 0.0; + v.series.push(Series { + name: "Cumulative %".into(), + values: bins + .iter() + .map(|b| { + run += b.count as f64; + Some(run / total) + }) + .collect(), + kind: Some(ChartType::Line), + secondary_axis: true, + ..Default::default() + }); + v.y2_min = Some(0.0); + v.y2_max = Some(1.0); + v.y2_major = Some(0.2); + v.y2_format = Some("0%".into()); + } + v.vary_colors = false; + v + } + + /// The box chart's groups (distinct category labels in order of appearance; one unnamed + /// group without categories) and, per series, each group's summary (None when it has no + /// points). + pub fn box_groups(&self) -> (Vec, Vec>>) { + let mut groups: Vec = Vec::new(); + for c in &self.categories { + if !groups.contains(c) { + groups.push(c.clone()); + } + } + if groups.is_empty() { + groups.push(String::new()); + } + let stats = self + .series + .iter() + .map(|s| { + groups + .iter() + .map(|g| { + let pts: Vec = s + .values + .iter() + .enumerate() + .filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g)) + .filter_map(|(_, v)| *v) + .filter(|v| v.is_finite()) + .collect(); + box_stats_by(pts, self.inclusive_quartiles) + }) + .collect() + }) + .collect(); + (groups, stats) + } +} + +/// Excel's QUARTILE.EXC position `p·(n+1)` (1-based) on sorted data, clamped to the ends. +fn quantile_exc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 + 1.0)).clamp(1.0, n as f64); + let i = pos.floor() as usize; + let frac = pos - i as f64; + let a = v[i.saturating_sub(1).min(n - 1)]; + let b = v[i.min(n - 1)]; + a + (b - a) * frac +} + +/// Excel's QUARTILE.INC position `p·(n-1)` (0-based) on sorted data. +fn quantile_inc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 - 1.0)).clamp(0.0, (n - 1) as f64); + let i = pos.floor() as usize; + let a = v[i.min(n - 1)]; + let b = v[(i + 1).min(n - 1)]; + a + (b - a) * (pos - i as f64) +} + +/// The summary of one group's points, with exclusive quartiles. +pub fn box_stats(pts: Vec) -> Option { + box_stats_by(pts, false) +} + +/// The summary of one group's points; `inclusive` picks QUARTILE.INC over QUARTILE.EXC. +pub fn box_stats_by(mut pts: Vec, inclusive: bool) -> Option { + if pts.is_empty() { + return None; + } + pts.sort_by(f64::total_cmp); + let q = |p: f64| if inclusive { quantile_inc(&pts, p) } else { quantile_exc(&pts, p) }; + let (q1, median, q3) = (q(0.25), q(0.5), q(0.75)); + let iqr = q3 - q1; + let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); + let inside: Vec = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect(); + let low = inside.first().copied().unwrap_or(q1).min(q1); + let high = inside.last().copied().unwrap_or(q3).max(q3); + let mean = pts.iter().sum::() / pts.len() as f64; + let outliers = pts.iter().copied().filter(|v| *v < lo_fence || *v > hi_fence).collect(); + Some(BoxStats { low, q1, median, q3, high, mean, outliers }) +} + +/// A node of a treemap or sunburst hierarchy: its label, the total of the leaves under it, and +/// its children (none for a leaf, which names its category index). +#[derive(Clone, Debug, PartialEq)] +pub struct TreeNode { + pub name: String, + pub value: f64, + pub leaf: Option, + pub children: Vec, +} + +/// Levels of hierarchy a chart may have (a hostile file cannot nest deeper). +pub const MAX_LEVELS: usize = 16; + +impl Chart { + /// The first series as a hierarchy: top-level nodes, each holding the consecutive + /// categories that share its label, down to the leaves. Without outer levels every + /// category is a top-level leaf. Negative and missing values count as zero. + pub fn category_tree(&self) -> Vec { + let Some(s) = self.series.first() else { return vec![] }; + let n = self.categories.len().max(s.values.len()); + let levels: Vec<&Vec> = self.category_levels.iter().take(MAX_LEVELS).collect(); + // Each leaf's path from the top level down, blanks filled from the row above. + let mut last: Vec = vec![String::new(); levels.len()]; + let paths: Vec> = (0..n) + .map(|i| { + let mut path = Vec::with_capacity(levels.len() + 1); + for (d, lv) in levels.iter().enumerate().rev() { + let l = lv.get(i).map(|x| x.trim()).unwrap_or(""); + if !l.is_empty() { + last[d] = l.to_string(); + // A new outer label starts fresh inner groups. + for inner in last.iter_mut().take(d) { + inner.clear(); + } + } + path.push(last[d].clone()); + } + path.push(self.categories.get(i).cloned().unwrap_or_default()); + path + }) + .collect(); + let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0); + fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec { + let mut out: Vec = Vec::new(); + let mut i = 0; + while i < rows.len() { + let (idx, path) = rows[i]; + if path.len() <= 1 { + out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] }); + i += 1; + continue; + } + let head = &path[0]; + let mut j = i; + while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head { + j += 1; + } + let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect(); + let children = build(&sub, value); + out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children }); + i = j; + } + out + } + let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect(); + build(&rows, &value) + } +} + +#[cfg(test)] +mod chartex_tests { + use super::*; + + fn obs(v: &[f64]) -> Chart { + Chart { + kind: ChartType::Histogram, + series: vec![Series { values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }], + ..Default::default() + } + } + + #[test] + fn histogram_bins_by_count_size_and_scott() { + let mut c = obs(&[1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 9.0]); + c.bin_count = Some(4); + let b = c.histogram_bins(); + assert_eq!(b.len(), 4); + assert_eq!((b[0].lo, b[0].hi), (1.0, 3.0)); + // (lo, hi] bins with the minimum in the first: 1, 2, 2, 3 | 4, 5 | - | 9. + assert_eq!(b.iter().map(|b| b.count).collect::>(), vec![4, 2, 0, 1]); + c.bin_count = None; + c.bin_size = Some(5.0); + assert_eq!(c.histogram_bins().iter().map(|b| b.count).collect::>(), vec![6, 1]); + c.bin_size = None; + let auto = c.histogram_bins(); + assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::() == 7); + // A tiny bin size is capped; one value gives one bin. + c.bin_size = Some(1e-12); + assert!(c.histogram_bins().len() <= MAX_BINS); + assert_eq!(obs(&[3.0]).histogram_bins(), vec![Bin { lo: 3.0, hi: 4.0, count: 1 }]); + assert!(obs(&[]).histogram_bins().is_empty()); + } + + #[test] + fn pareto_view_sorts_bins_and_adds_the_cumulative_line() { + let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]); + c.kind = ChartType::Pareto; + c.bin_count = Some(3); + let v = c.histogram_view(); + assert_eq!(v.kind, ChartType::Combo); + assert_eq!(v.series[0].values, vec![Some(3.0), Some(2.0), Some(1.0)]); + let cum = &v.series[1]; + assert!(cum.secondary_axis && cum.kind == Some(ChartType::Line)); + assert_eq!(cum.values.last(), Some(&Some(1.0))); + assert_eq!(v.categories[0], "(3.67, 6.33]"); + } + + #[test] + fn box_stats_use_exclusive_quartiles_and_fence_outliers() { + let b = box_stats(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]).unwrap(); + // QUARTILE.EXC on 8 points: positions 2.25, 4.5, 6.75. + assert_eq!((b.q1, b.median, b.q3), (2.25, 4.5, 6.75)); + assert_eq!(b.outliers, vec![100.0]); + assert_eq!((b.low, b.high), (1.0, 7.0)); + assert!(box_stats(vec![]).is_none()); + let one = box_stats(vec![5.0]).unwrap(); + assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0)); + } + + #[test] + fn inclusive_quartiles_follow_quartile_inc() { + let b = box_stats_by(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0], true).unwrap(); + // QUARTILE.INC on 8 points: positions 1.75, 3.5, 5.25 (0-based). + assert_eq!((b.q1, b.median, b.q3), (2.75, 4.5, 6.25)); + } + + #[test] + fn category_tree_groups_runs_and_fills_blank_parents() { + let mut c = Chart::sample(ChartType::Treemap); + let t = c.category_tree(); + assert_eq!(t.iter().map(|n| n.name.as_str()).collect::>(), ["Q1", "Q2", "Q3"]); + assert_eq!(t[0].children.len(), 3); + assert_eq!(t[0].children[1].leaf, Some(1)); + assert!((t[0].value - (4.2 + 2.8 + 3.6)).abs() < 1e-9); + // Blank parents continue the label above; two outer levels nest. + c.category_levels = vec![ + vec!["a".into(), "".into(), "b".into(), "".into(), "c".into(), "".into(), "".into(), "".into()], + vec!["H1".into(), "".into(), "".into(), "".into(), "H2".into(), "".into(), "".into(), "".into()], + ]; + let t = c.category_tree(); + assert_eq!(t.len(), 2); + assert_eq!(t[0].children.iter().map(|n| n.name.as_str()).collect::>(), ["a", "b"]); + assert_eq!(t[1].children[0].children.len(), 4); + // No levels: every category is a leaf. + c.category_levels.clear(); + assert!(c.category_tree().iter().all(|n| n.leaf.is_some())); + } + + #[test] + fn box_groups_follow_category_labels() { + let c = Chart::sample(ChartType::BoxWhisker); + let (groups, stats) = c.box_groups(); + assert_eq!(groups, vec!["Group A".to_string(), "Group B".to_string()]); + assert_eq!(stats.len(), 2); + assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some))); + } +} diff --git a/crates/model/src/edit.rs b/crates/model/src/edit.rs index 1c38bf74..33c2a8bf 100644 --- a/crates/model/src/edit.rs +++ b/crates/model/src/edit.rs @@ -459,44 +459,39 @@ pub fn next_case(s: &str) -> &'static str { /// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`. pub fn change_case(s: &str, mode: &str) -> String { - match mode { - "upper" => s.to_uppercase(), - "lower" => s.to_lowercase(), - "toggle" => s.chars().map(|c| if c.is_uppercase() { c.to_lowercase().collect::() } else { c.to_uppercase().collect() }).collect(), - "title" => { - let mut out = String::new(); - let mut start = true; - for c in s.chars() { - if start && c.is_alphabetic() { - out.extend(c.to_uppercase()); - start = false; - } else { - out.extend(c.to_lowercase()); - } - if c.is_whitespace() { - start = true; + change_case_parts(&[s], mode).concat() +} + +/// Change the case of consecutive pieces of text (the runs of a paragraph) as one string, so +/// `title` and `sentence` carry their word/sentence state across piece boundaries. Each output +/// piece is the changed form of the matching input piece, even where a character changes length. +pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec { + let mut start = true; + parts + .iter() + .map(|s| match mode { + "upper" => s.to_uppercase(), + "lower" => s.to_lowercase(), + "toggle" => s.chars().map(|c| if c.is_uppercase() { c.to_lowercase().collect::() } else { c.to_uppercase().collect() }).collect(), + _ => { + // `title` restarts after whitespace, sentence case after sentence punctuation. + let title = mode == "title"; + let mut out = String::new(); + for c in s.chars() { + if start && c.is_alphabetic() { + out.extend(c.to_uppercase()); + start = false; + } else { + out.extend(c.to_lowercase()); + } + if (title && c.is_whitespace()) || (!title && matches!(c, '.' | '!' | '?')) { + start = true; + } } + out } - out - } - _ => { - // sentence - let mut out = String::new(); - let mut start = true; - for c in s.chars() { - if start && c.is_alphabetic() { - out.extend(c.to_uppercase()); - start = false; - } else { - out.extend(c.to_lowercase()); - } - if matches!(c, '.' | '!' | '?') { - start = true; - } - } - out - } - } + }) + .collect() } #[cfg(test)] diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 3faec84d..22dc42d3 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -398,6 +398,31 @@ fn grey(v: u8, opacity: f32) -> Fill { } } +/// Premultiplied RGBA (the renderer's pixels) to the straight RGBA the writer stores. +fn straight(rgba: &mut [u8]) { + for px in rgba.as_chunks_mut::<4>().0 { + let a = px[3] as u32; + if a != 0 && a != 255 { + for c in &mut px[..3] { + *c = (((*c as u32 * 255 + a / 2) / a).min(255)) as u8; + } + } + } +} + +/// A backdrop raster as one PDF image, composited onto white (slides are normally opaque). Equal +/// backdrops become equal images, which the writer stores once. +fn backdrop(mut img: deckcraft_render::Image) -> Image { + for px in img.pixels.as_chunks_mut::<4>().0 { + let a = px[3] as u32; + for c in &mut px[..3] { + *c = (*c as u32 + (255 - a)).min(255) as u8; + } + px[3] = 255; + } + Image::from_rgba8(img.pixels, img.width, img.height) +} + impl Exporter<'_> { fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Result { if let Some(font) = self.fonts.get(&face.id()) { @@ -415,25 +440,26 @@ impl Exporter<'_> { let k = b.width() / sw; // Raster at the requested dpi relative to the box size on paper. let scale = (b.width() / 72.0 * self.opts.dpi.clamp(36.0, 600.0)) / sw; - let img = deckcraft_render::render_slide(pres, i, &RenderOpts { scale, threads: 4, ..Default::default() }); - if img.width > 0 && img.height > 0 { - let (w, h) = (img.width, img.height); + let ropts = RenderOpts { scale, threads: 4, ..Default::default() }; + // Two rasters per slide: the backdrop (background, master and layout graphics) is the same + // on every slide that shares its layout, so the writer stores it once, and the slide's own + // shapes draw over it (#845). + let back = deckcraft_render::render_slide_part(pres, i, deckcraft_render::SlidePart::Backdrop, &ropts); + let front = deckcraft_render::render_slide_part(pres, i, deckcraft_render::SlidePart::Content, &ropts); + let (w, h) = (back.width, back.height); + if w > 0 + && h > 0 + && let Some(size) = Size::from_wh(b.width() as f32, b.height() as f32) + { + s.push_transform(&Transform::from_translate(b.x0 as f32, b.y0 as f32)); + s.draw_image(backdrop(back), size); // Taken, not copied: a print-resolution slide is ~16 MB. - let mut rgba = img.pixels; - for px in rgba.as_chunks_mut::<4>().0 { - // Composite onto white (slides are normally opaque). - let a = px[3] as u32; - for c in &mut px[..3] { - *c = (*c as u32 + (255 - a)).min(255) as u8; - } - px[3] = 255; - } - let image = Image::from_rgba8(rgba, w, h); - if let Some(size) = Size::from_wh(b.width() as f32, b.height() as f32) { - s.push_transform(&Transform::from_translate(b.x0 as f32, b.y0 as f32)); - s.draw_image(image, size); - s.pop(); + let mut rgba = front.pixels; + if rgba.as_chunks::<4>().0.iter().any(|px| px[3] != 0) { + straight(&mut rgba); + s.draw_image(Image::from_rgba8(rgba, w, h), size); } + s.pop(); } if self.opts.frame_slides && self.opts.layout != PageLayout::Slides { let p = kurbo::Shape::to_path(&b, 0.1); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index fd858884..2bc5c41a 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -432,6 +432,95 @@ fn master_text_wraps_and_keeps_mixed_formatting() { assert!(export(&p, &PdfOptions { layout: PageLayout::Notes, ..Default::default() }).unwrap().starts_with(b"%PDF")); } +/// Slides that share their layout store their backdrop raster once in the exported PDF, over which +/// each slide's own shapes draw as their own raster (#845). +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn slides_share_one_backdrop_raster_and_keep_their_pixels() { + use deckcraft_model::{Shape, ShapeId, Xfrm}; + use hayro_syntax::object::{Dict, Number, Stream}; + + /// The image XObjects the export stores, one entry per distinct object: the pages that draw + /// it, its filters, color channel, alpha channel (the SMask) and pixel size. + fn images(p: &Presentation) -> Vec<(Vec, Vec, Vec, Option>, (u32, u32))> { + let bytes = export(p, &opts(PageLayout::Slides)).expect("export"); + let pdf = hayro_syntax::Pdf::new(bytes).expect("pdf"); + let mut out: Vec<(String, Vec, Vec, Vec, Option>, (u32, u32))> = Vec::new(); + for (n, page) in pdf.pages().iter().enumerate() { + let resources = page.raw().get::(b"Resources").expect("resources"); + let xobjects = resources.get::(b"XObject").expect("xobjects"); + for key in xobjects.keys() { + let stream = xobjects.get::(key).expect("stream"); + let id = format!("{:?}", stream.obj_id()); + match out.iter_mut().find(|(seen, ..)| *seen == id) { + Some(entry) => entry.1.push(n), + None => { + let dict = stream.dict(); + let alpha = dict.get::(b"SMask").map(|mask| mask.decoded().expect("decoded").into_owned()); + let size = ( + dict.get::(b"Width").expect("width").as_f64() as u32, + dict.get::(b"Height").expect("height").as_f64() as u32, + ); + out.push((id, vec![n], stream.filters().into_vec(), stream.decoded().expect("decoded").into_owned(), alpha, size)); + } + } + } + } + out.into_iter().map(|(_, pages, filters, rgb, alpha, size)| (pages, filters, rgb, alpha, size)).collect() + } + + // The raster scale the exporter uses for one slide per page. + let scale = |p: &Presentation| (p.slide_size.width / 72.0 * opts(PageLayout::Slides).dpi) / p.slide_size.width; + + // Three slides without shapes: every page is pure backdrop and shares one raster, which is + // exactly what the renderer draws. + let mut p = deck(3); + for slide in &mut p.slides { + Arc::make_mut(slide).shapes.clear(); + } + let stored = images(&p); + assert_eq!(stored.len(), 1, "all three pages must share one backdrop raster"); + let (pages, filters, rgb, alpha, size) = stored.into_iter().next().expect("one image"); + assert_eq!(pages, [0, 1, 2]); + assert_eq!(filters, [hayro_syntax::Filter::FlateDecode], "backdrop artwork stays lossless"); + assert!(alpha.is_none(), "the backdrop is opaque and needs no alpha mask"); + let expect = deckcraft_render::render_slide(&p, 0, &RenderOpts { scale: scale(&p), threads: 4, ..Default::default() }); + assert_eq!(size, (expect.width, expect.height)); + let want: Vec = expect.pixels.as_chunks::<4>().0.iter().flat_map(|px| px[..3].iter().copied()).collect(); + assert_eq!(rgb, want, "the shared raster is exactly what the renderer draws"); + + // Slides with different text still share the backdrop: one raster on every page plus one per + // slide's own shapes. + let stored = images(&deck(3)); + assert_eq!(stored.len(), 4, "one shared backdrop plus three content rasters"); + assert_eq!(stored.iter().filter(|(pages, ..)| pages.len() == 3).count(), 1, "the backdrop is the only raster every page draws"); + + // A slide's own shapes draw over the shared backdrop as a second raster, stored as straight + // RGBA (color channel plus alpha mask), never the renderer's premultiplied pixels. + let mut p = deck(1); + Arc::make_mut(&mut p.slides[0]).shapes.push(Shape { + id: ShapeId(9002), + xfrm: Some(Xfrm::new(20.0, 20.0, 400.0, 300.0)), + fill: Some(deckcraft_model::Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_model::Rgba::rgb(200, 30, 30)))), + ..Default::default() + }); + let stored = images(&p); + assert_eq!(stored.len(), 2, "shared backdrop plus the slide's content"); + let (_, filters, rgb, alpha, _) = stored.into_iter().find(|(_, _, _, alpha, _)| alpha.is_some()).expect("content raster"); + assert_eq!(filters, [hayro_syntax::Filter::FlateDecode]); + let expect = deckcraft_render::render_slide_part( + &p, + 0, + deckcraft_render::SlidePart::Content, + &RenderOpts { scale: scale(&p), threads: 4, ..Default::default() }, + ); + let straight = expect.to_straight(); + let want_rgb: Vec = straight.as_chunks::<4>().0.iter().flat_map(|px| px[..3].iter().copied()).collect(); + let want_alpha: Vec = straight.as_chunks::<4>().0.iter().map(|px| px[3]).collect(); + assert_eq!(rgb, want_rgb, "content colors are straight, not premultiplied"); + assert_eq!(alpha, Some(want_alpha), "content alpha keeps the shape's coverage"); +} + #[test] fn glyph_ranges_follow_shaping_clusters() { // "fl" ligature: its glyph maps to both characters, the rest stay one to one (#164). diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index ec37b764..1b1dc5b4 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -63,6 +63,12 @@ pub fn import(bytes: &[u8]) -> Result { read::import(bytes) } +/// [`import`], plus the part names of slides the file lists but that couldn't be read and were +/// left out. +pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxError> { + read::import_checked(bytes) +} + /// Read the theme of a `.thmx` file, or of a `.pptx` / `.potx`'s first master, with its media. pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec), PptxError> { read::import_theme(bytes) diff --git a/crates/pptx/src/opc.rs b/crates/pptx/src/opc.rs index 0e7f86fe..b8406dc8 100644 --- a/crates/pptx/src/opc.rs +++ b/crates/pptx/src/opc.rs @@ -281,8 +281,8 @@ fn percent_decode(s: &str) -> String { /// Resolve a relative target against a base directory into a part name. pub fn resolve(base: &str, target: &str) -> String { - let target = percent_decode(target); - let target = target.split('#').next().unwrap_or(""); + let target = percent_decode(target.split('#').next().unwrap_or("")); + let target = target.as_str(); let mut segs: Vec<&str> = if target.starts_with('/') { vec![] } else { base.split('/').filter(|s| !s.is_empty()).collect() }; for s in target.split('/') { match s { @@ -441,6 +441,12 @@ impl PackageOut { mod tests { use super::*; + #[test] + fn resolve_keeps_encoded_hash_and_strips_fragment() { + assert_eq!(resolve("ppt/slides", "../media/a%23b.png"), "ppt/media/a#b.png"); + assert_eq!(resolve("ppt/slides", "../media/a.png#frag"), "ppt/media/a.png"); + } + fn zip_of(entries: &[(&str, usize)]) -> Vec { let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 9055b2d9..73cc416c 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -473,7 +473,7 @@ fn copy_part( return false; } let content_type = pkg.content_type(part).unwrap_or_default(); - out.push(ChartPart { name, content_type, bytes: bytes.to_vec() }); + out.push(ChartPart { name, content_type, bytes: bytes.to_vec(), native_entry: None }); seen.insert(key, true); let rels_name = crate::opc::rels_path(part); if crate::opc::sidecar_part(&rels_name) @@ -482,7 +482,12 @@ fn copy_part( { seen.insert(crate::opc::norm(&rels_name), true); let name = pkg.original_name(&rels_name).unwrap_or(&rels_name).trim_start_matches('/').to_string(); - out.push(ChartPart { name, content_type: "application/vnd.openxmlformats-package.relationships+xml".into(), bytes: rels_bytes.to_vec() }); + out.push(ChartPart { + name, + content_type: "application/vnd.openxmlformats-package.relationships+xml".into(), + bytes: rels_bytes.to_vec(), + native_entry: None, + }); let targets: Vec = pkg.rels(part).items.into_iter().filter(|r| !r.external).map(|r| r.target).collect(); for target in targets { copy_part(pkg, &target, out, seen, depth + 1); diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index ac83dfc0..979cdb70 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -387,6 +387,13 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec< } pub fn import(bytes: &[u8]) -> Result { + import_checked(bytes).map(|(p, _)| p) +} + +/// [`import`], plus the parts of the slide list the file names but that couldn't be read (a +/// missing or truncated slide): those slides are left out, so a caller can say so (#203). +pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxError> { + let mut lost_slides: Vec = vec![]; let pkg = Package::open(bytes)?; let root_rels = pkg.rels(""); let pres_path = root_rels.first_of("officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "ppt/presentation.xml".into()); @@ -447,6 +454,7 @@ pub fn import(bytes: &[u8]) -> Result { for (file_id, path) in &slide_entries { if !docs.contains_key(&opc::norm(path)) { log::warn!("pptx: slide {path} is missing or unreadable; skipped"); + lost_slides.push(path.clone()); continue; } let id = SlideId(imp.alloc()); @@ -677,7 +685,7 @@ pub fn import(bytes: &[u8]) -> Result { p.media = std::mem::take(&mut imp.media); p.next_id = imp.next; p.fix_next_id(); - Ok(p) + Ok((p, lost_slides)) } /// Embedded font faces in one file. Past this, further typefaces are dropped. diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs index ec4bffd2..3839d992 100644 --- a/crates/pptx/src/write/timing.rs +++ b/crates/pptx/src/write/timing.rs @@ -655,21 +655,10 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm w.close("p:seq"); } for (trig, effs) in triggered { - // Everything a trigger starts plays from its click. - let effs: Vec = effs - .into_iter() - .enumerate() - .map(|(i, e)| Eff { - start: if i == 0 { - AnimStart::OnClick - } else if e.start == AnimStart::OnClick { - AnimStart::AfterPrevious - } else { - e.start - }, - ..e - }) - .collect(); + // The first step starts with the button's click. Later On Click effects must remain + // separate steps; With/After Previous effects still belong to their preceding step. + let effs: Vec = + effs.into_iter().enumerate().map(|(i, e)| Eff { start: if i == 0 { AnimStart::OnClick } else { e.start }, ..e }).collect(); let clicks = group(effs); let trig_cond = |w: &mut W| { w.open("p:cond", A::new().a("evt", "onClick").a("delay", 0)); diff --git a/crates/pptx/tests/malformed.rs b/crates/pptx/tests/malformed.rs index 633d339e..774a5ee9 100644 --- a/crates/pptx/tests/malformed.rs +++ b/crates/pptx/tests/malformed.rs @@ -264,3 +264,26 @@ fn export_of_odd_models_is_valid() { let q = deckcraft_pptx::import(&deckcraft_pptx::export(&empty).expect("export")).expect("import"); assert!(!q.masters.is_empty()); } + +/// A slide the file lists but that can't be read is left out, and `import_checked` names it +/// where `import` would have said nothing (#203). +#[test] +fn unreadable_slide_is_reported() { + let mut p = defaults::new_presentation(None); + let mut second = p.slides.first().expect("a slide").as_ref().clone(); + second.id = deckcraft_model::SlideId(second.id.0 + 1000); + p.slides.push(std::sync::Arc::new(second)); + let bytes = deckcraft_pptx::export(&p).expect("export"); + + let (q, lost) = deckcraft_pptx::import_checked(&bytes).expect("import"); + assert_eq!((q.slides.len(), lost.len()), (2, 0), "{lost:?}"); + + let mut es = entries(&bytes); + let slide2 = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide2.xml").expect("slide2"); + slide2.1 = b" Presentation { + let mut p = defaults::new_presentation(None); + let shapes: Vec<_> = ["Button", "Other button", "Content"] + .into_iter() + .map(|name| Shape { + id: ShapeId(p.alloc_id().expect("id")), + name: name.into(), + xfrm: Some(Xfrm::new(40.0, 40.0, 200.0, 100.0)), + text: Some(TextBody::from_text("First\nSecond\nThird")), + ..Default::default() + }) + .collect(); + Arc::make_mut(&mut p.slides[0]).shapes = shapes; + p +} + +fn round(p: &Presentation) -> Presentation { + deckcraft_pptx::import(&deckcraft_pptx::export(p).expect("export")).expect("import") +} + +fn slide_xml(p: &Presentation) -> String { + let bytes = deckcraft_pptx::export(p).expect("export"); + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut xml = String::new(); + z.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("xml"); + xml +} + +#[test] +fn triggered_click_steps_survive_import_edit_save_reopen() { + use AnimStart::{AfterPrevious, OnClick, WithPrevious}; + let mut p = deck(); + let ids: Vec<_> = p.slides[0].shapes.iter().map(|s| s.id).collect(); + let starts = [OnClick, WithPrevious, AfterPrevious, OnClick, WithPrevious, OnClick]; + for (i, start) in starts.into_iter().enumerate() { + Arc::make_mut(&mut p.slides[0]).animations.push(Animation { + shape: ids[2], + trigger: Some(ids[0]), + start, + effect: "appear".into(), + delay_ms: i as u32 * 100, + ..Default::default() + }); + } + // An independent button and the main sequence must not borrow this button's steps. + for trigger in [Some(ids[1]), None] { + for start in [OnClick, AfterPrevious, OnClick] { + Arc::make_mut(&mut p.slides[0]).animations.push(Animation { shape: ids[2], trigger, start, ..Default::default() }); + } + } + let mut q = round(&p); + for pass in 0..2 { + for trigger in [Some(ids[0]), Some(ids[1]), None] { + let state = |deck: &Presentation| { + deck.slides[0].animations.iter().filter(|a| a.trigger == trigger).map(|a| (a.start, a.delay_ms)).collect::>() + }; + assert_eq!(state(&q), state(&p), "trigger {trigger:?}, pass {pass}"); + } + assert_eq!(q.slides[0].animations.len(), p.slides[0].animations.len()); + Arc::make_mut(&mut q.slides[0]).shapes[2].name = "Edited content".into(); + q = round(&q); + } + let xml = slide_xml(&p); + assert_eq!(xml.matches("nodeType=\"interactiveSeq\"").count(), 2); + assert_eq!(xml.matches("nodeType=\"clickEffect\"").count(), 7); + // The first step in each interactive sequence begins at zero; subsequent steps wait. + assert_eq!(xml.matches("delay=\"indefinite\"").count(), 5); +} + +#[test] +fn triggered_paragraph_build_keeps_a_click_for_each_paragraph() { + let mut p = deck(); + let ids: Vec<_> = p.slides[0].shapes.iter().map(|s| s.id).collect(); + Arc::make_mut(&mut p.slides[0]).animations = + vec![Animation { shape: ids[2], trigger: Some(ids[0]), text_build: TextBuild::ByParagraph, start: AnimStart::OnClick, ..Default::default() }]; + for q in [p.clone(), round(&p)] { + let xml = slide_xml(&q); + assert_eq!(xml.matches("nodeType=\"clickEffect\"").count(), 3); + assert_eq!(xml.matches("delay=\"indefinite\"").count(), 2); + let back = round(&q); + assert_eq!(back.slides[0].animations.len(), 1); + assert_eq!(back.slides[0].animations[0].text_build, TextBuild::ByParagraph); + assert_eq!(back.slides[0].animations[0].trigger, Some(ids[0])); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 93b0dc95..cf124a9f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -238,6 +238,10 @@ pub struct RenderOpts<'a> { pub date_text: Option, /// The local date and time date fields show (`None`: the system clock). pub now: Option, + /// Leave the slide background out (neither the white underlay nor the background fill), so + /// only what is on the slide is drawn over transparency: PNG export with a transparent + /// background. Shapes filled with the background (`useBgFill`) still show it. + pub transparent_background: bool, } impl Default for RenderOpts<'_> { @@ -257,6 +261,7 @@ impl Default for RenderOpts<'_> { clear: None, date_text: None, now: None, + transparent_background: false, } } } @@ -794,6 +799,10 @@ impl Renderer { fn background(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: Option<&Slide>, view: Affine) { let (fill, ph) = resolve::background(rctx, slide); + if f.opts.transparent_background { + self.bg = Some((fill, ph, view)); + return; + } let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height); ctx.set_transform(view); let path = r.to_path(0.1); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 05d7c91a..4ee1c889 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -992,6 +992,33 @@ fn a_background_is_laid_over_white() { assert!(g[3] == 255 && (g[0] as i32 - 127).abs() <= 3, "half-transparent black over white is grey: {g:?}"); } +#[test] +fn a_transparent_background_leaves_the_slide_background_out() { + // #784: PNG export with a transparent background draws only what is on the slide. The + // background (a solid colour here) and its white underlay are left out; the shapes, and a + // shape filled with the background (`useBgFill`), still draw. + use deckcraft_model::style::Fill; + let blue = ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 200)); + let red = Shape { + id: ShapeId(784), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(220, 0, 0)))), + ..Default::default() + }; + let bg_filled = Shape { id: ShapeId(785), xfrm: Some(Xfrm::new(500.0, 100.0, 200.0, 100.0)), fill: Some(Fill::Background), ..Default::default() }; + let mut p = deck_with(vec![red, bg_filled]); + std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(blue) }); + let opaque = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + assert_eq!(opaque.pixel(20, 20), [0, 0, 200, 255], "without the option the background draws"); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, transparent_background: true, ..Default::default() }); + assert_eq!(img.pixel(20, 20), [0, 0, 0, 0], "no background, no white underlay"); + assert_eq!(img.pixel(200, 150), [220, 0, 0, 255], "the slide's shapes still draw"); + assert_eq!(img.pixel(600, 150), [0, 0, 200, 255], "a background-filled shape still shows the background"); + // The default theme background (a style reference) is left out too. + let img = render_slide(&deck_with(vec![]), 0, &RenderOpts { scale: 0.25, transparent_background: true, ..Default::default() }); + assert!(img.pixels.as_chunks::<4>().0.iter().all(|px| px[3] == 0), "a blank slide is fully transparent"); +} + #[test] fn colour_glyphs_paint_their_layers() { // A COLRv0 test font (crates/render/tests/fixtures): U+E000 is a red square with a blue diff --git a/crates/render/src/transition.rs b/crates/render/src/transition.rs index bd6e3317..ee760767 100644 --- a/crates/render/src/transition.rs +++ b/crates/render/src/transition.rs @@ -113,6 +113,7 @@ fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> { clear: o.clear, date_text: o.date_text.clone(), now: o.now, + transparent_background: o.transparent_background, } } diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 79302877..c7dfa024 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -16,7 +16,10 @@ deckcraft-fonts = { workspace = true } unicode-linebreak = { workspace = true } unicode-segmentation = { workspace = true } unicode-bidi = { workspace = true } +# Unconditional: `is_valid`/`weekday` use chrono on every target, including wasm32. +# Only the wasm-safe `std` baseline here (inherited from the workspace entry). +chrono = { workspace = true } # The local clock for date fields (no clock on wasm: fields keep their saved text). [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -chrono = { workspace = true } +chrono = { workspace = true, features = ["clock"] } diff --git a/crates/text/src/datetime.rs b/crates/text/src/datetime.rs index 8844f299..56282675 100644 --- a/crates/text/src/datetime.rs +++ b/crates/text/src/datetime.rs @@ -42,27 +42,17 @@ impl DateTime { /// A real calendar date and time of day. fn is_valid(&self) -> bool { - let leap = self.year % 4 == 0 && (self.year % 100 != 0 || self.year % 400 == 0); - let days = match self.month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 if leap => 29, - 2 => 28, - _ => return false, - }; - (1..=9999).contains(&self.year) && (1..=days).contains(&self.day) && self.hour < 24 && self.minute < 60 && self.second < 61 + let date_ok = (1..=9999).contains(&self.year) && chrono::NaiveDate::from_ymd_opt(self.year, self.month, self.day).is_some(); + date_ok && self.hour < 24 && self.minute < 60 && self.second < 61 } - /// Day of the week, 0 = Sunday (days since 1970-01-01, Howard Hinnant's algorithm). + /// Day of the week, 0 = Sunday. fn weekday(&self) -> usize { - let (y, m, d) = (i64::from(self.year), i64::from(self.month), i64::from(self.day)); - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let days = era * 146_097 + doe - 719_468; - (days + 4).rem_euclid(7) as usize + match chrono::NaiveDate::from_ymd_opt(self.year, self.month, self.day) { + // `chrono::Weekday::num_days_from_sunday` is 0 = Sunday. + Some(d) => chrono::Datelike::weekday(&d).num_days_from_sunday() as usize, + None => 0, + } } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9acc26c0..d09f5b32 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -413,6 +413,9 @@ struct ParaShaped { /// Size of the paragraph's first run (spacing percentages, empty lines). lead_size: f64, lead_face: Arc, + /// Size of the paragraph mark (`a:endParaRPr`): what an empty last line is sized by. + end_size: f64, + end_face: Arc, bullet: Option<(String, Style)>, #[allow(dead_code)] level: u8, @@ -709,7 +712,9 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, faces: vec![], props: ParaProps::default(), lead_size: 18.0, - lead_face: face, + lead_face: face.clone(), + end_size: 18.0, + end_face: face, bullet: None, level: 0, rtl: false, @@ -741,6 +746,9 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, let lead_style = make_style(ctx, &lead, scale, opts.prompt_color); let lead_size = lead_style.size; let lead_face = lead_style.face.clone(); + // The paragraph mark, which ends the last line when no break does. + let end_style = make_style(ctx, &resolve::run(ctx, shape, para, ¶.end_props), scale, opts.prompt_color); + let (end_size, end_face) = (end_style.size, end_style.face.clone()); // Bullet. let bullet = match &props.bullet { Some(Bullet::Char { char }) => { @@ -789,7 +797,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, } _ => None, }; - ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl } + ParaShaped { cells, styles, faces, props, lead_size, lead_face, end_size, end_face, bullet, level: para.level, rtl } } /// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows @@ -1712,10 +1720,21 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } } if size == 0.0 { - let (a, d) = line_metrics(&p.lead_face, p.lead_size); + // PowerPoint sizes an empty line by the mark that ends it: the `a:rPr` of the + // `a:br` that closes it, or `a:endParaRPr` for the paragraph's last line -- not + // by the paragraph's first run (#780). An empty *paragraph* already lands on + // `end_props` through `lead`, and keeps the same height here. + let brk = p.cells.get(rl.end).filter(|c| c.ch == '\u{b}').and_then(|c| p.styles.get(c.style)); + let (face, sz) = match brk { + Some(st) => (&st.face, st.size), + None if rl.end >= p.cells.len() => (&p.end_face, p.end_size), + // Anything else empty (a line of tabs alone) keeps the old measure. + None => (&p.lead_face, p.lead_size), + }; + let (a, d) = line_metrics(face, sz); asc = a; desc = d; - size = p.lead_size; + size = sz; } let natural = (asc + desc).max(size * 1.0); let lh = match props.line_spacing { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index a9d936cd..bc0f24f5 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1467,3 +1467,99 @@ fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() { assert_eq!(l.lines.len(), 2); assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item"); } + +/// A run of `text` at `size` points. +fn sized_run(text: &str, size: f64) -> Run { + let mut r = Run::new(text); + r.props.size = Some(size); + r +} + +/// A line break whose own run properties are `size` points. +fn sized_break(size: f64) -> Run { + let mut r = Run { text: String::new(), props: Default::default(), kind: deckcraft_model::text::RunKind::Break }; + r.props.size = Some(size); + r +} + +/// The height one line of `size`-point text takes in this shape: the yardstick the empty-line +/// tests measure against, so they do not depend on the test font's ascent and descent. +fn one_line_height(p: &Presentation, sh: &mut Shape, size: f64) -> f64 { + sh.text.as_mut().unwrap().paragraphs = vec![Paragraph { runs: vec![sized_run("X", size)], ..Default::default() }]; + let l = lay(p, sh, 400.0, 400.0); + l.lines[0].bottom - l.lines[0].top +} + +#[test] +fn an_empty_line_is_sized_by_the_break_that_ends_it() { + // PowerPoint sizes an empty line by the mark that closes it, not by the paragraph's first + // run (#780). Case A of the report: 40pt text, a 40pt break, a 10pt break, 40pt text, so + // the empty second line is a 10pt line and not a 40pt one. + let (p, mut sh) = setup("x", 400.0, 400.0); + let (h10, h40, h60, h20) = ( + one_line_height(&p, &mut sh, 10.0), + one_line_height(&p, &mut sh, 40.0), + one_line_height(&p, &mut sh, 60.0), + one_line_height(&p, &mut sh, 20.0), + ); + let put = |sh: &mut Shape, runs: Vec| { + sh.text.as_mut().unwrap().paragraphs = vec![Paragraph { runs, ..Default::default() }]; + }; + + put(&mut sh, vec![sized_run("A1", 40.0), sized_break(40.0), sized_break(10.0), sized_run("A2", 40.0)]); + let a = lay(&p, &sh, 400.0, 400.0); + assert_eq!(a.lines.len(), 3, "{:?}", a.lines.iter().map(|l| l.top).collect::>()); + let empty_a = a.lines[2].top - a.lines[1].top; + assert!((empty_a - h10).abs() < 0.5, "a 10pt break should give a 10pt line ({h10}), got {empty_a}"); + assert!((empty_a - h40).abs() > 1.0, "and not the 40pt first run's line ({h40})"); + + // Case C: a 60pt break closing the empty line of a 20pt paragraph pushes the third line + // down, the opposite direction from the first run's size -- so this cannot pass by the old + // behaviour under a different font. + put(&mut sh, vec![sized_run("C1", 20.0), sized_break(20.0), sized_break(60.0), sized_run("C2", 20.0)]); + let c = lay(&p, &sh, 400.0, 400.0); + let empty_c = c.lines[2].top - c.lines[1].top; + assert!((empty_c - h60).abs() < 0.5, "a 60pt break should give a 60pt line ({h60}), got {empty_c}"); + assert!((empty_c - h20).abs() > 1.0, "and not the 20pt first run's line ({h20})"); +} + +#[test] +fn an_empty_last_line_is_sized_by_the_paragraph_mark() { + // Case B: 40pt text and a trailing 40pt break, with the paragraph mark at 10pt. No break + // closes the empty last line, so it takes the mark's size. + let (p, mut sh) = setup("x", 400.0, 400.0); + let (h10, h40) = (one_line_height(&p, &mut sh, 10.0), one_line_height(&p, &mut sh, 40.0)); + { + let t = sh.text.as_mut().unwrap(); + let mut para = Paragraph { runs: vec![sized_run("B1", 40.0), sized_break(40.0)], ..Default::default() }; + para.end_props.size = Some(10.0); + t.paragraphs = vec![para]; + } + let l = lay(&p, &sh, 400.0, 400.0); + assert_eq!(l.lines.len(), 2, "the trailing break leaves an empty last line"); + let h = l.lines[1].bottom - l.lines[1].top; + assert!((h - h10).abs() < 0.5, "a 10pt paragraph mark should give {h10}, got {h}"); + assert!((h - h40).abs() > 1.0, "and not the 40pt first run's line ({h40})"); +} + +#[test] +fn an_empty_paragraph_keeps_its_paragraph_mark_height() { + // Case D, the control: an empty *paragraph* already took `end_props` before this change, + // through `lead`, and must be unaffected by it. + let (p, mut sh) = setup("x", 400.0, 400.0); + let h10 = one_line_height(&p, &mut sh, 10.0); + { + let t = sh.text.as_mut().unwrap(); + let mut empty = Paragraph::default(); + empty.end_props.size = Some(10.0); + t.paragraphs = vec![ + Paragraph { runs: vec![sized_run("D1", 40.0)], ..Default::default() }, + empty, + Paragraph { runs: vec![sized_run("D2", 40.0)], ..Default::default() }, + ]; + } + let l = lay(&p, &sh, 400.0, 400.0); + assert_eq!(l.lines.len(), 3); + let h = l.lines[1].bottom - l.lines[1].top; + assert!((h - h10).abs() < 0.5, "a 10pt empty paragraph should give {h10}, got {h}"); +} diff --git a/crates/ui-egui/src/align_tests.rs b/crates/ui-egui/src/align_tests.rs new file mode 100644 index 00000000..969ef2fe --- /dev/null +++ b/crates/ui-egui/src/align_tests.rs @@ -0,0 +1,103 @@ +//! Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns +//! to the slide and several to each other, until the other choice is picked for that selection. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use deckcraft_engine::Session; +use deckcraft_model::ShapeId; +use egui::accesskit::Toggled; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// A blank slide with three rectangles at different places; `select` picks which are selected. +fn harness(select: &[usize]) -> (Harness<'static, SlideApp>, Vec) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let ids: Vec = [[100.0, 100.0], [300.0, 200.0], [500.0, 250.0]] + .iter() + .map(|[x, y]| { + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 80.0, 60.0]})).unwrap(); + ShapeId(r["id"].as_u64().unwrap() as u32) + }) + .collect(); + let picked: Vec = select.iter().map(|&i| ids[i].0).collect(); + s.execute("selection.select", &json!({"ids": picked})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "Shape Format".into(); + let mut h = Harness::builder().with_size(egui::vec2(1800.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + (h, ids) +} + +fn pick(h: &mut Harness<'_, SlideApp>, item: &str) { + h.get_by_label("Align").click(); + h.run_steps(2); + h.get_by_label(item).click(); + h.run_steps(2); +} + +fn marked(h: &mut Harness<'_, SlideApp>) -> (bool, bool) { + h.get_by_label("Align").click(); + h.run_steps(2); + let m = ( + h.get_by_label("Align to Slide").accesskit_node().toggled() == Some(Toggled::True), + h.get_by_label("Align Selected Objects").accesskit_node().toggled() == Some(Toggled::True), + ); + // Esc would also clear the selection: a second click on Align closes the menu. + h.get_by_label("Align").click(); + h.run_steps(2); + m +} + +fn pos(h: &Harness<'_, SlideApp>, id: ShapeId) -> (f64, f64) { + let x = h.state().session.active().unwrap().shape(id).unwrap().xfrm.unwrap(); + (x.x, x.y) +} + +#[test] +fn align_to_slide_moves_align_left_to_the_slide_edge() { + let (mut h, ids) = harness(&[0, 1]); + pick(&mut h, "Align to Slide"); + pick(&mut h, "Align Left"); + assert_eq!(pos(&h, ids[0]).0, 0.0); + assert_eq!(pos(&h, ids[1]).0, 0.0); +} + +#[test] +fn one_object_aligns_to_the_slide_and_two_to_each_other() { + let (mut h, ids) = harness(&[1]); + assert_eq!(marked(&mut h), (true, false)); + pick(&mut h, "Align Left"); + assert_eq!(pos(&h, ids[1]).0, 0.0); + + let (mut h, ids) = harness(&[0, 1]); + assert_eq!(marked(&mut h), (false, true)); + pick(&mut h, "Align Left"); + assert_eq!(pos(&h, ids[1]).0, 100.0, "the left edge of the selection"); +} + +#[test] +fn the_choice_stays_until_the_selection_changes() { + let (mut h, ids) = harness(&[0, 1]); + pick(&mut h, "Align to Slide"); + pick(&mut h, "Align Left"); + pick(&mut h, "Align Top"); + assert_eq!(pos(&h, ids[0]), (0.0, 0.0)); + assert_eq!(pos(&h, ids[1]), (0.0, 0.0)); + assert_eq!(marked(&mut h), (true, false)); + + // Another selection starts again from its own default. + h.state_mut().run("selection.select", json!({"ids": [ids[1].0, ids[2].0]})).unwrap(); + h.run_steps(2); + assert_eq!(marked(&mut h), (false, true)); + pick(&mut h, "Align Bottom"); + assert_eq!(pos(&h, ids[1]).1, 250.0, "the bottom of the selection, not of the slide"); +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index f7bfe253..999900d3 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -175,6 +175,8 @@ pub struct Backstage { pub file_type: &'static str, pub handouts_per_page: u8, pub pdf_hidden: bool, + /// Export › Change File Type › PNG: leave the slide background out (#784). + pub png_transparent: bool, } impl Backstage { @@ -197,6 +199,7 @@ impl Backstage { file_type: "pptx", handouts_per_page: 3, pdf_hidden: false, + png_transparent: false, } } @@ -1310,8 +1313,15 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { } } ui.add_space(12.0); + let png = bs.file_type == "png"; + if png { + ui.checkbox(&mut bs.png_transparent, "Transparent background") + .on_hover_text("Leave the slide background out: only what is on the slide is drawn"); + ui.add_space(8.0); + } if square_button(ui, Icon::SaveAs, "Save As", true).clicked() { - app.export_as(bs.file_type, json!({})); + let extra = if png && bs.png_transparent { json!({"transparentBackground": true}) } else { json!({}) }; + app.export_as(bs.file_type, extra); } } } diff --git a/crates/ui-egui/src/backstage_tests.rs b/crates/ui-egui/src/backstage_tests.rs index 9b3906a2..c29ef47d 100644 --- a/crates/ui-egui/src/backstage_tests.rs +++ b/crates/ui-egui/src/backstage_tests.rs @@ -302,6 +302,32 @@ fn save_as_and_export_run_the_file_commands() { let _ = std::fs::remove_dir_all(&dir); } +/// Export › Change File Type › PNG offers "Transparent background" (#784): the PNG it saves +/// leaves the slide background out. Other types don't show the option. +#[test] +fn change_file_type_png_can_leave_the_background_transparent() { + let dir = temp_dir("png-clear"); + let (services, _) = saving_into(&dir); + let mut h = harness(SlideApp::new(Session::with_new(), services)); + h.state_mut().run("app.backstage", json!({"page": "export"})).unwrap(); + h.run_steps(3); + click_on_page(&h, "Change File Type"); + h.run_steps(3); + assert!(h.query_by_label("Transparent background").is_none(), "only PNG has the option"); + click_on_page(&h, "PNG Picture (*.png)"); + h.run_steps(2); + h.get_by_label("Transparent background").click(); + h.run_steps(2); + assert!(h.state().backstage.as_ref().is_some_and(|b| b.png_transparent)); + click_on_page(&h, "Save As"); + h.run_steps(3); + let title = h.state().session.active().unwrap().title(); + let png = std::fs::read(dir.join(format!("{title}.png"))).unwrap(); + let img = deckcraft_render::decode_image(&png).unwrap(); + assert_eq!(img.get_pixel(2, 2).0[3], 0, "the background is left out"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Close closes a saved presentation at once and asks about unsaved changes first. #[test] fn close_closes_and_asks_about_unsaved_changes() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 576a2297..2393bd61 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1593,6 +1593,18 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { d.fields.insert("pdfHidden".into(), hidden.to_string()); } } + let png = matches!(fmt.as_str(), "png" | "pngAll"); + if png { + ui.add_space(4.0); + let mut clear = d.get("pngTransparent", "false") == "true"; + if ui + .checkbox(&mut clear, "Transparent background") + .on_hover_text("Leave the slide background out: only what is on the slide is drawn") + .changed() + { + d.fields.insert("pngTransparent".into(), clear.to_string()); + } + } let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) }; if let Err(e) = &gif_params { ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim)); @@ -1618,6 +1630,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let extra = match fmt.as_str() { "pdf" => pdf_params, "gif" => gif_params, + _ if png && d.get("pngTransparent", "false") == "true" => json!({"transparentBackground": true}), _ => json!({}), }; app.export_as(&fmt, extra); @@ -2095,6 +2108,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.checkbox(&mut pr.ac_days, "Capitalize names of days"); ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key"); ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (½)"); + ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript"); ui.checkbox(&mut pr.autocorrect, "Replace text as you type"); ui.add_space(4.0); let mut from = d.get("from", ""); @@ -3432,6 +3446,47 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The Export… dialog offers "Transparent background" for PNG only, and the picture it + /// writes then leaves the slide background out (#784). + #[test] + fn export_dialog_writes_a_png_with_a_transparent_background() { + use egui::vec2; + use egui_kittest::kittest::Queryable as _; + use serde_json::json; + + use crate::SlideApp; + let dir = std::env::temp_dir().join(format!("deckcraft-ui-png-clear-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("slide.png"); + let picked = path.to_string_lossy().to_string(); + let services = crate::Services { pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), ..Default::default() }; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(deckcraft_engine::Session::with_new(), services), + ); + harness.run_steps(2); + harness.state_mut().run("app.exportDialog", json!({})).unwrap(); + harness.run_steps(2); + assert!(harness.query_by_label("Transparent background").is_none(), "PDF has no transparent background"); + harness.get_by_label("JPEG image (current slide)").click_accesskit(); + harness.run_steps(2); + assert!(harness.query_by_label("Transparent background").is_none(), "nor has JPEG"); + harness.get_by_label("PNG images (current slide)").click_accesskit(); + harness.run_steps(2); + harness.get_by_label("Transparent background").click_accesskit(); + harness.run_steps(2); + assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("pngTransparent")).map(String::as_str), Some("true")); + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "the dialog closes after exporting"); + let img = deckcraft_render::decode_image(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(img.get_pixel(2, 2).0[3], 0, "the background is left out"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn no_link_opens_as_a_new_web_address() { let seed = seed_from(CurrentLink::None, &[]); diff --git a/crates/ui-egui/src/huge_deck_tests.rs b/crates/ui-egui/src/huge_deck_tests.rs index b22cb400..2ff81bdf 100644 --- a/crates/ui-egui/src/huge_deck_tests.rs +++ b/crates/ui-egui/src/huge_deck_tests.rs @@ -37,3 +37,40 @@ fn the_outline_of_a_huge_deck_lays_out_only_what_is_in_view() { assert!(h.query_all_by_role(Role::TextInput).count() < 200); assert_eq!(h.state().session.doc().unwrap().selection.slide, 1500); } + +/// The thumbnail pane of an 800-slide deck renders only the thumbnails in view. When every row +/// asked for one, a deck bigger than the 600-entry thumbnail cache evicted its own thumbnails before +/// the pane was done, so the pane kept rendering and flashing white forever (#969). +#[test] +fn the_thumbnail_pane_of_a_huge_deck_renders_only_the_slides_in_view() { + let mut h = harness(800); + h.run_steps(60); + let settled = h.state().textures.thumb_count(); + assert!(settled > 3 && settled < 60, "{settled} thumbnails rendered"); + // The visible thumbnails are done: more frames render nothing new. + h.run_steps(10); + assert_eq!(h.state().textures.thumb_count(), settled); + // Scrolled far down, the slides now in view get their thumbnails, and only those. + h.hover_at(egui::pos2(100.0, 450.0)); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -40_000.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.run_steps(60); + let scrolled = h.state().textures.thumb_count(); + assert!(scrolled > settled && scrolled < 2 * settled + 10, "{scrolled} thumbnails after scrolling (was {settled})"); +} + +/// The same in the Slide Sorter: only the cells in view render a thumbnail (#969). +#[test] +fn the_sorter_of_a_huge_deck_renders_only_the_slides_in_view() { + let mut h = harness(800); + h.state_mut().ui.view = crate::ViewMode::Sorter; + h.run_steps(60); + let settled = h.state().textures.thumb_count(); + assert!(settled > 3 && settled < 80, "{settled} thumbnails rendered"); + h.run_steps(10); + assert_eq!(h.state().textures.thumb_count(), settled); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 88674070..eef9b1b3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -496,6 +496,8 @@ pub struct SlideApp { pub(crate) thumb_drag: Option<(usize, usize)>, /// Selection pane: folded groups, renaming, drag and drop. pub selpane: selpane::State, + /// Arrange ▸ Align: Align to Slide (`true`) or Align Selected Objects, ticked for that selection. + pub(crate) align_to: Option<(Vec, bool)>, /// View › New Window: a second window on one presentation. `None` when it is closed. pub doc_window: Option, /// Slide Sorter: folded sections, inline rename, dragging a whole section. @@ -572,6 +574,7 @@ impl SlideApp { ui_fonts: uifonts::UiFonts::default(), thumb_drag: None, selpane: selpane::State::default(), + align_to: None, doc_window: None, sorter: sorter::State::default(), slides_focused: false, @@ -769,7 +772,8 @@ impl SlideApp { // Every slide for "all slides", one download each, named like the desktop export (#64). let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); let mut any = false; - for (file, bytes) in web_image_files(&doc, slide, all, &name, ext) { + let transparent = extra.get("transparentBackground").and_then(Value::as_bool).unwrap_or(false); + for (file, bytes) in web_image_files(&doc, slide, all, &name, ext, transparent) { any = self.download(&file, Ok(bytes)) || any; } return any; @@ -1184,6 +1188,26 @@ impl SlideApp { Ok(()) } + /// Files the system hands over (the command line, a double-click through the file + /// association), opened in order. When the last one is a PowerPoint Show (`.ppsx`), its slide + /// show starts at once from where Play from Start begins, as PowerPoint does; anything else + /// opens for editing, and File › Open opens a `.ppsx` for editing too. Returns the files that + /// couldn't be opened, with the reason. + pub fn open_from_system(&mut self, paths: &[String]) -> Vec<(String, String)> { + let mut failed = Vec::new(); + let mut last = None; + for p in paths { + match self.open_path(p) { + Ok(()) => last = Some(p), + Err(e) => failed.push((p.clone(), e)), + } + } + if last.is_some_and(|p| p.to_ascii_lowercase().ends_with(".ppsx")) { + self.start_show(self.show_start_index(), false); + } + failed + } + /// A presentation was opened from `path`: first in the recent list, shown in Normal view. pub(crate) fn note_recent(&mut self, path: &str) { self.status = None; // an older error (a missing file just before this one) must not linger @@ -1987,8 +2011,16 @@ pub fn now_ms() -> f64 { } /// Images for the browser download: the current slide, or every slide (named `1.png`, -/// `2.png`... like the native export) when `all`. -fn web_image_files(doc: &deckcraft_model::Presentation, current: usize, all: bool, name: &str, ext: &str) -> Vec<(String, Vec)> { +/// `2.png`... like the native export) when `all`. `transparent` leaves the slide +/// background out (PNG only, #784). +fn web_image_files( + doc: &deckcraft_model::Presentation, + current: usize, + all: bool, + name: &str, + ext: &str, + transparent: bool, +) -> Vec<(String, Vec)> { let slides: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; let single = slides.len() == 1; let scale = 2.0; @@ -1997,7 +2029,8 @@ fn web_image_files(doc: &deckcraft_model::Presentation, current: usize, all: boo .into_iter() .enumerate() .map(|(k, i)| { - let img = deckcraft_render::render_slide(doc, i, &deckcraft_render::RenderOpts { scale, ..Default::default() }); + let opts = deckcraft_render::RenderOpts { scale, transparent_background: transparent && ext == "png", ..Default::default() }; + let img = deckcraft_render::render_slide(doc, i, &opts); let bytes = if ext == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }; let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) }; (file, bytes) @@ -2098,14 +2131,38 @@ mod tests { doc.slides.push(first); } assert_eq!(doc.slides.len(), 3); - let all = web_image_files(&doc, 1, true, "Deck", "png"); + let all = web_image_files(&doc, 1, true, "Deck", "png", false); let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect(); assert_eq!(names, ["Deck1.png", "Deck2.png", "Deck3.png"]); - let one = web_image_files(&doc, 1, false, "Deck", "png"); + let one = web_image_files(&doc, 1, false, "Deck", "png", false); assert_eq!(one.len(), 1); assert_eq!(one[0].0, "Deck.png"); } + /// The web PNG download honours "Transparent background" (#784): the slide background is + /// left out, so the corner is transparent; a JPEG download stays opaque. + #[test] + fn web_png_export_can_leave_the_background_transparent() { + use std::cell::RefCell; + use std::rc::Rc; + let got: Rc)>>> = Rc::default(); + let log = got.clone(); + let services = Services { + download: Some(Box::new(move |name: &str, b: &[u8]| log.borrow_mut().push((name.to_string(), b.to_vec())))), + ..Default::default() + }; + let mut app = SlideApp::new(Session::with_new(), services); + let corner_alpha = |bytes: &[u8]| deckcraft_render::decode_image(bytes).unwrap().get_pixel(2, 2).0[3]; + assert!(app.export_as("png", json!({}))); + assert!(app.export_as("png", json!({"transparentBackground": true}))); + assert!(app.export_as("jpeg", json!({"transparentBackground": true}))); + let got = got.borrow(); + assert_eq!(got.len(), 3); + assert_eq!(corner_alpha(&got[0].1), 255, "opaque by default"); + assert_eq!(corner_alpha(&got[1].1), 0, "transparent background"); + assert_eq!(corner_alpha(&got[2].1), 255, "JPEG has no transparency"); + } + fn title_text(app: &SlideApp) -> String { let d = app.session.active().unwrap(); let sh = &d.doc.slides[0].shapes[0]; diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 37d20a4e..318cacfb 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -199,4 +199,93 @@ mod tests { assert!(harness.state().opening.is_none()); assert_eq!(harness.state().session.documents().len(), 1); } + + /// A deck of `slides` slides saved as `file` (its extension picks the format). + fn saved_as(file: &str, slides: usize) -> String { + let mut s = Session::with_new(); + for _ in 1..slides { + s.execute("slide.new", &serde_json::json!({})).unwrap(); + } + let dir = std::env::temp_dir().join(format!("deckcraft-opening-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(file).to_string_lossy().to_string(); + s.execute("file.saveAs", &serde_json::json!({"path": path})).unwrap(); + path + } + + /// #862: a PowerPoint Show handed over by the system (command line, double-click) starts its + /// slide show at once, full screen and from the first slide. + #[test] + fn a_ppsx_from_the_system_starts_the_show() { + let path = saved_as("Talk.PPSX", 3); + let mut app = SlideApp::new(Session::new(), crate::Services::default()); + assert!(app.open_from_system(std::slice::from_ref(&path)).is_empty()); + assert_eq!(app.session.documents().len(), 1); + let show = app.show.as_ref().expect("the slide show started"); + assert_eq!(show.state.slide, 0); + assert!(!show.reading, "full screen, not Reading View"); + std::fs::remove_file(&path).ok(); + } + + /// #862: a `.pptx` from the system opens for editing, and so does a `.ppsx` followed by a + /// `.pptx`: the show would otherwise play the deck opened last. + #[test] + fn a_pptx_from_the_system_opens_for_editing() { + let pptx = saved_as("edit.pptx", 3); + let ppsx = saved_as("first.ppsx", 2); + let mut app = SlideApp::new(Session::new(), crate::Services::default()); + assert!(app.open_from_system(std::slice::from_ref(&pptx)).is_empty()); + assert!(app.show.is_none()); + assert!(app.open_from_system(&[ppsx.clone(), pptx.clone()]).is_empty()); + assert_eq!(app.session.documents().len(), 3); + assert!(app.show.is_none(), "the last file is a .pptx"); + std::fs::remove_file(&pptx).ok(); + std::fs::remove_file(&ppsx).ok(); + } + + /// #862: File › Open opens a `.ppsx` for editing, as PowerPoint does. + #[test] + fn file_open_opens_a_ppsx_for_editing() { + let path = saved_as("open.ppsx", 2); + let mut app = SlideApp::new(Session::new(), crate::Services::default()); + app.open_path_in_background(&path); + wait(&mut app); + assert_eq!(app.session.active().unwrap().doc.slides.len(), 2); + assert!(app.show.is_none()); + assert_eq!(app.ui.view, crate::ViewMode::Normal); + std::fs::remove_file(&path).ok(); + } + + /// #862: Esc, or a click past the end screen, ends the `.ppsx` show and leaves the deck open in + /// the editor (PowerPoint closes it instead). + #[test] + fn leaving_the_ppsx_show_keeps_the_deck_open() { + let path = saved_as("leave.ppsx", 2); + for leave in [egui::Key::Escape, egui::Key::ArrowRight] { + let mut app = SlideApp::new(Session::new(), crate::Services::default()); + assert!(app.open_from_system(std::slice::from_ref(&path)).is_empty()); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(3); + assert!(harness.state().show.is_some(), "the show is running"); + if leave == egui::Key::ArrowRight { + for _ in 0..2 { + harness.key_press(egui::Key::ArrowRight); + harness.run_steps(2); + } + assert!(harness.state().show.as_ref().is_some_and(|s| s.ended), "the end screen"); + } + harness.key_press(leave); + harness.run_steps(2); + assert!(harness.state().show.is_none(), "{leave:?} ends the show"); + assert_eq!(harness.state().session.documents().len(), 1, "{leave:?}: the deck stays open"); + assert_eq!(harness.state().ui.view, crate::ViewMode::Normal); + } + std::fs::remove_file(&path).ok(); + } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 3e43a68b..3a679dd1 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1,7 +1,7 @@ //! Title bar, ribbon tabs and the ribbon card with every tab's groups. use deckcraft_engine::Target; -use deckcraft_model::{ShapeKind, anim::AnimClass}; +use deckcraft_model::{ShapeId, ShapeKind, anim::AnimClass}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::{Value, json}; @@ -1134,6 +1134,9 @@ pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { } pub fn align_menu(app: &mut SlideApp, ui: &mut Ui) { + let selection = app.session.active().map(|d| d.selection.shapes.clone()).unwrap_or_default(); + let to_slide = align_to_slide(app.align_to.as_ref(), &selection); + let to = if to_slide { "slide" } else { "selection" }; for (l, e, ic) in [ ("Align Left", "left", Icon::AlignObjLeft), ("Align Center", "center", Icon::AlignObjCenter), @@ -1142,11 +1145,28 @@ pub fn align_menu(app: &mut SlideApp, ui: &mut Ui) { ("Align Middle", "middle", Icon::AlignObjMiddle), ("Align Bottom", "bottom", Icon::AlignObjBottom), ] { - menu_item(app, ui, ic, l, "arrange.align", json!({"edge": e})); + menu_item(app, ui, ic, l, "arrange.align", json!({"edge": e, "to": to})); } ui.separator(); - menu_item(app, ui, Icon::DistributeH, "Distribute Horizontally", "arrange.distribute", json!({"dir": "horizontal"})); - menu_item(app, ui, Icon::DistributeV, "Distribute Vertically", "arrange.distribute", json!({"dir": "vertical"})); + menu_item(app, ui, Icon::DistributeH, "Distribute Horizontally", "arrange.distribute", json!({"dir": "horizontal", "to": to})); + menu_item(app, ui, Icon::DistributeV, "Distribute Vertically", "arrange.distribute", json!({"dir": "vertical", "to": to})); + ui.separator(); + let en = enabled(app, "arrange.align"); + for (l, slide) in [("Align to Slide", true), ("Align Selected Objects", false)] { + if widgets::check_item(ui, l, en, to_slide == slide).clicked() { + app.align_to = Some((selection.clone(), slide)); + ui.close(); + } + } +} + +/// Align to Slide or to the selected objects, as in PowerPoint: one object aligns to the slide and +/// several to each other, unless the other choice was ticked for this same selection. +fn align_to_slide(choice: Option<&(Vec, bool)>, selection: &[ShapeId]) -> bool { + match choice { + Some((ids, to_slide)) if ids.as_slice() == selection => *to_slide, + _ => selection.len() < 2, + } } pub fn menu_item(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { @@ -3454,6 +3474,10 @@ mod effects_tests; #[path = "master_layout_tests.rs"] mod master_layout_tests; +#[cfg(test)] +#[path = "align_tests.rs"] +mod align_tests; + #[cfg(test)] mod title_bar_tests { use super::*; diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index a875773c..51f859af 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -650,12 +650,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.step_end = now; } } else if show.trans.is_none() { - // Auto-advance (After: timings) or autoplay preview. + // Auto-advance (After: timings) or autoplay preview. An automatic first step is started + // by `ShowState` as the slide appears; starting it here too replayed it after Back (#844). let idle = now - show.step_end.max(show.slide_start); let cur_step = show.state.step; - let next_auto = show.timeline(&doc).step_is_auto(cur_step); let n_steps = show.timeline(&doc).steps(); - let step_due = cur_step < n_steps && ((show.autoplay && idle > 0.3) || next_auto); + let step_due = show.autoplay && cur_step < n_steps && idle > 0.3; let holding = show.grid || show.zoom.is_some() || show.zoom_arm; if !holding && (step_due || (!show.rehearse && show.state.auto_advance_due(&doc, idle) && !show.ended)) { actions.push(show.state.next(&doc)); @@ -918,10 +918,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { ctx.set_cursor_icon(egui::CursorIcon::None); } else if show.zoom_arm || show.grid { ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + } else if now - show.last_move > 2.5 && !show.reading { + // Idle first: a still pointer hides also over a clickable shape, such as a playing clip. + ctx.set_cursor_icon(egui::CursorIcon::None); + ctx.request_repaint_after(std::time::Duration::from_millis(500)); } else if pointing { ctx.set_cursor_icon(egui::CursorIcon::PointingHand); - } else if now - show.last_move > 2.5 && !show.reading { - ctx.set_cursor_icon(egui::CursorIcon::None); ctx.request_repaint_after(std::time::Duration::from_millis(500)); } let pen_col = theme::to_color32(doc.show.pen_color); @@ -1881,6 +1883,32 @@ mod tests { use super::*; + /// #968: over a shape a click acts on (a clip with its play trigger, a link) the pointing hand + /// never gave way to the idle hide, so a still pointer stayed on the playing video. + #[test] + fn a_still_cursor_hides_over_a_clickable_shape() { + let mut session = deckcraft_engine::Session::with_new(); + let size = session.active().unwrap().doc.slide_size; + let r = session.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap(); + session.execute("insert.hyperlink", &json!({"url": "https://example.org", "ids": [r["id"]]})).unwrap(); + let app = SlideApp::new(session, crate::Services::default()); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.25).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(3); + harness.state_mut().start_show(0, false); + harness.run_steps(2); + harness.hover_at(pos2(500.0, 350.0)); + harness.run_steps(2); + assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); + harness.run_steps(12); + assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for 3 s: hidden"); + } + /// Send real egui key events to the presenter UI, without the audience window's handler. #[test] fn presenter_navigation_uses_its_own_keyboard_events() { @@ -2246,4 +2274,38 @@ mod tests { let doc = s.active().unwrap().doc.clone(); assert_eq!(next_rect(right, &doc).width(), 400.0); } + + #[test] + fn back_leaves_a_slide_whose_first_animation_is_with_previous() { + // #844: Back on such a slide undid its first step, and the next frame played that step + // again by itself, so Back never reached the slide before. + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + let id = session.active().unwrap().doc.slides[1].shapes[0].id; + session + .execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id.0], "start": "withPrevious", "duration": 500})) + .unwrap(); + let app = SlideApp::new(session, crate::Services::default()); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.run_steps(3); + harness.state_mut().start_show(0, false); + harness.run_steps(2); + harness.key_press(egui::Key::ArrowRight); + // The first step plays by itself and ends (0.5 s; a frame is 0.25 s). + harness.run_steps(8); + let st = &harness.state().show.as_ref().unwrap().state; + assert_eq!((st.slide, st.step, st.playing), (1, 1, false), "on slide 2, fully built"); + for _ in 0..5 { + harness.key_press(egui::Key::ArrowLeft); + harness.run_steps(2); + } + let st = &harness.state().show.as_ref().unwrap().state; + assert_eq!(st.slide, 0, "five Backs leave slide 2 (step {}, playing {})", st.step, st.playing); + } } diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 4c97605c..3c840860 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -203,13 +203,17 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32, let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th)), ppp); rects.push((i, cell)); let p = ui.painter(); - match app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) { + // Only cells in view (plus a little margin) ask for a thumbnail, so a deck bigger + // than the thumbnail cache does not evict its own thumbnails forever (#969). + let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * cell.height())).intersects(cell); + let tex = if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) } else { None }; + match tex { Some(tex) => { p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } None => { p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE); - need_more = true; + need_more |= near; } } let id = g.doc.slides.get(i).map(|s| s.id); diff --git a/crates/ui-egui/src/spelling_tests.rs b/crates/ui-egui/src/spelling_tests.rs index e88ea23f..5d6cf700 100644 --- a/crates/ui-egui/src/spelling_tests.rs +++ b/crates/ui-egui/src/spelling_tests.rs @@ -233,7 +233,9 @@ fn notes_menu_language_dialog_and_repeated_words() { if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_NOTES"), h.render()) { let _ = img.save(path); } - // Right-click the notes' first word. + // Right-click the notes' first word, once the (background) notes check is in. + assert_eq!(h.state_mut().squiggles.settle_notes("Remmeber the the plan", "xx-NM"), vec![(0, 8), (13, 16)]); + h.run_steps(2); let notes = h.query_all_by_role(Role::MultilineTextInput).next().map(|n| n.rect()).unwrap(); right_click(&mut h, egui::pos2(notes.left() + 14.0, notes.top() + 8.0)); if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_NOTES_MENU"), h.render()) { @@ -245,6 +247,7 @@ fn notes_menu_language_dialog_and_repeated_words() { let notes_text = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().doc.slides[0].notes_text(); assert_eq!(notes_text(&h), "Remember the the plan"); // The repeated "the": its squiggle starts after "Remember the ". + h.state_mut().squiggles.settle_notes("Remember the the plan", "xx-NM"); h.run_steps(4); let x = h.query_all_by_role(Role::MultilineTextInput).next().map(|n| n.rect()).unwrap(); let mut found = false; diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index d6c9c02c..eb26ab19 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -115,7 +115,11 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { p.text(pos2(row.min.x + 14.0, img.min.y + 20.0), Align2::CENTER_TOP, "★", theme::font(10.0), t.text_faint); } let wpx = (img.width() * ppp).round() as u32; - match app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) { + // Only rows in view (plus a little margin) ask for a thumbnail: with every row asking, a + // deck bigger than the thumbnail cache evicts its own thumbnails and never settles (#969). + let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * row.height())).intersects(row); + let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) } else { None }; + match tex { Some(tex) => { p.image( tex.id(), @@ -126,7 +130,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { } None => { p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE); - need_more = true; + need_more |= near; } } if selected { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 6903f1bf..67549ce3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -152,6 +152,25 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele (if split { main_resp } else { resp }, arrow_resp) } +/// A menu choice ticked in the icon column (Align to Slide…), its text lined up with the +/// [`small_button`] items around it. +pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let font = theme::font(12.0); + let tw = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0; + let (rect, resp) = ui.allocate_exact_size(vec2(24.0 + tw, 22.0), if enabled { Sense::click() } else { Sense::hover() }); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, enabled, on, label)); + let painter = ui.painter_at(rect); + if let Some(c) = bg(ui, &resp, false, &t) { + painter.rect_filled(rect, CornerRadius::same(4), c); + } + if on { + icons::paint(&painter, Rect::from_center_size(pos2(rect.min.x + 12.0, rect.center().y), vec2(17.0, 17.0)), Icon::Check, t.text, !enabled); + } + painter.text(pos2(rect.min.x + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, if enabled { t.text } else { t.text_faint }); + resp +} + /// A square icon toggle (B, I, U, alignment…). pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) -> Response { let t = Tokens::get(ui.ctx()); diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 7cd99abf..9e1fdeec 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -67,7 +67,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Effect Options (direction, sequence by paragraph) | yes | yes | | Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; repeat and bounce end round-trip; rewind lost on PPTX save (#77) | | By-paragraph text builds, sub-bullets follow parents | yes | yes | -| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step | +| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step. Separate click steps, mixed starts and paragraph builds survive PPTX save (`trigger_steps.rs`) | | Triggers on a media bookmark | yes | no | | Animation Painter | yes | yes (#54) | | Animation Pane: list, reorder, remove | yes | yes | @@ -94,6 +94,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Date | Change | Summary | |---|---|---| +| 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores | | 2026-10-14 | trivial | Community PR wave folded: the Animation Pane timeline row says yes (it landed), preview split into canvas (yes) and from the selected effect (no); estimates drop the timeline and transition sounds (~15–27 h) | | 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) | | 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide's animations on the editor canvas; preview from the selected effect is still open | diff --git a/docs/architecture.md b/docs/architecture.md index 31bac443..2a600e57 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (community PR wave 2026-10-14: 320 engine commands, 41 dialogs, the `metafile` crate, 1,641 tests; duplicated L2/L5/L6 rows and headers folded; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: minor, community PR wave 2026-10-14: 320 engine commands, 41 dialogs, the `metafile` crate, 1,641 tests; duplicated L2/L5/L6 rows and headers folded; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what @@ -64,8 +64,10 @@ overlays an invisible real-text layer with embedded font subsets. - **PPTX** (`crates/pptx`): lenient reader (unknown parts skipped, malformed parts logged and ignored, sizes and depths bounded, zip bombs capped), writer that PowerPoint opens without repair. Unknown elements round-trip through `Opaque` and `raw_ext` fields. -- **Native** (`crates/format`): zip with `mimetype`, `presentation.json` and `media/`; readers - ignore unknown fields; `.slidecraft` files from before the rename still open. +- **Native** (`crates/format`): zip with `mimetype`, `presentation.json`, `media/` and + `chart-parts/`. Chart relationships and payload references stay in the manifest; binary + chart parts use bounded ZIP reads and share the media byte/item budgets. Earlier inline chart + bytes still load and migrate on save; `.slidecraft` files from before the rename still open. - Formats and their gaps: [file-format-parity.md](file-format-parity.md). ## Quality gates @@ -79,6 +81,7 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-14 | minor | Community PR wave folded: one L2/L5/L6 row each; command registry 320 (`cmd!` registrations in `cmd/*`), 41 dialogs (titled in `dialogs.rs`), 202k lines, 24 crates (`metafile` added at L0), 1,641 tests. The per-PR rows below give the counts each PR saw on its own branch | | 2026-10-14 | minor | Size and Position dialog (`shape.sizePosition`): position, size, rotation, scale and lock aspect in one undo step. 227 commands, 28 dialogs | | 2026-10-14 | trivial | Command registry: 228 (`comment.next`, `comment.prev`) | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e715d1c7..4ec7b72e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -3,6 +3,10 @@ Start the app with `--control `. The server listens on `127.0.0.1` only and speaks JSON lines: one request object per line, one reply per line. +A line that is not a JSON object with a string `method` (an HTTP request, text that isn't JSON, +bytes that aren't UTF-8) gets one error reply and the server closes the connection; nothing sent +after it runs. Blank lines are skipped. + The connected MCP client requires the reply's numeric `id` to match its request, `ok` to be a boolean, and exactly one of `result` (success) or a string `error` (failure). It caps each reply at 16 MiB including its terminating newline, accepts CRLF, and rejects EOF inside a frame. Invalid @@ -45,7 +49,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly | | `ui.resize` / `ui.focus` | | Window control | | `show.start` / `show.end` | `{from?: slide index, custom?: name, reading?}` | Slide show. `custom` plays that custom show from its first slide and does not change Set Up. With no `from` and no `custom`, playback starts at the first slide of the show order | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`) | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`, or `{"path": "slide.png", "transparentBackground": true}` for a PNG without the slide background) | The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude and other agents; `deckcraft-cli app --method METHOD [PARAMS]` sends one request from a shell. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a5f72fe7..87a36322 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -18,7 +18,7 @@ PowerPoint opens and saves; Viewer = opens only). |---|---|---|---|---|---| | `.pptx` presentation | Editor | yes | yes | Broad (see below); an owned corpus of decks we author round-trips tables, charts, animations, media, sections, masters and layouts, comments, links and actions, custom shows, rotated groups, inherited placeholders, text effects and body layout, theme effects, fills, bevels, connectors, 3-D models and animation bounce end. Not measured against PowerPoint renders or decks from outside this repo | `crates/pptx/tests/corpus.rs`, `corpus_kinds.rs`, `corpus_elements.rs`, `corpus_style.rs`, `corpus_themes.rs`, `corpus_charts.rs`, `corpus_media.rs`, `roundtrip.rs`, `sample.rs`, `malformed.rs`, `sniff.rs`; opened in PowerPoint by hand 2026-10-06 | | `.potx` template | Editor | yes (as a presentation) | no: written with the `.pptx` content type | `file.saveTemplate` is Save As under another name | none | -| `.ppsx` slide show | Editor | yes (as a presentation) | no: written as `.pptx` content | Doesn't start in show mode | none | +| `.ppsx` slide show | Editor | yes (as a presentation) | no: written as `.pptx` content | Handed over by the system (command line, file association) it starts the slide show, as in PowerPoint; File › Open opens it for editing. The macOS Finder event arrives with #432 | `crates/ui-egui/src/opening.rs` tests | | `.pptm` / `.potm` / `.ppsm` macro-enabled | Editor | no | no | Should open and keep `vbaProject.bin` without running it | none | | `.ppt` / `.pot` / `.pps` 97–2003 binary | Editor | no | no | Owner decision; import only would be enough | none | | `.odp` OpenDocument | Editor | no | no | | none | @@ -26,7 +26,7 @@ PowerPoint opens and saves; Viewer = opens only). | `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests | | PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests | | Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` outline; `.md` as Markdown (headings are slides, list items bullets) (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests | -| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size | render and engine tests | +| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size. PNG can leave the slide background out (`transparentBackground`, Export… “Transparent background”, `deckcraft-cli render --transparent`), so only the slide's content is opaque (#784) | render, engine, UI and CLI tests | | TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none | | Animated GIF of the deck | export | n/a | yes (`file.export` format `gif`, Export…, `deckcraft-cli`, MCP `export`) | One frame per slide from the slide renderer; size small/medium/large/extra large or 16–3840 px wide, 0.1–600 s per slide, loop forever or play once, slide range, hidden slides skipped; a 256-colour palette per frame (exact, or NeuQuant above 256 colours). Transitions and build animations are not animated | `crates/engine/src/gif_export.rs` tests (decoded frames, size, delay, loop, hidden slides, hostile params), MCP, CLI and export dialog tests | | MP4 / MOV video of the deck | export | n/a | no | Needs timeline render + H.264 encode | none | @@ -34,6 +34,22 @@ PowerPoint opens and saves; Viewer = opens only). ## Pictures and media inside decks +Native files retain a nonempty chart `kept` sidecar: relationships, part names, content types +and `nativeEntry` references live in `presentation.json`; exact binary payloads live in distinct +`chart-parts/` ZIP members. This keeps workbooks and drawings through a native save before +exporting back to PPTX without expanding workbook bytes into JSON numbers. Chart parts and +media share the 4 GiB inflated-byte and 100,000-item budgets; each member is read through the +existing 2 GiB entry limit. Missing chart members are reported by `load_checked` and left empty. +A present chart member that fails decoding, CRC or its entry limit refuses the file immediately, +so later corrupt members cannot reuse the remaining inflation budget. Invalid, reused or +ambiguous references are refused. A synthetic 3 MiB payload test +checks the ZIP layout and bounded reads, alongside shared-budget and all-owner round trips. + +Older native files without a sidecar still load. Earlier inline chart byte arrays are read +under the existing manifest-size limit, then charged to the shared budget; saving migrates them +to ZIP members. Builds predating chart-sidecar support ignore it and lose it on resave. The +intermediate build that required inline `bytes` cannot read the new referenced representation. + | Kind | PowerPoint | DeckCraft | Notes | |---|---|---|---| | PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 | @@ -103,6 +119,12 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | +| 2026-10-15 | trivial | Native `.deckcraft` files serialize a nonempty chart `kept` sidecar (relationships, part names, content types and bytes). Older files without it still load; empty sidecars are omitted. Headless PPTX → edit → native save/reopen → PPTX checks keep the workbook and drawing bytes; editing chart data still drops the stale workbook | +| 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen | +| 2026-10-15 | minor | A `.pptx` slide that can't be read is reported instead of dropped in silence: `file.open` and `file.openBytes` return `lostSlides`, the app shows a message and opens a repaired copy (Save asks for a new name), and `deckcraft-cli` warns on stderr (#203) | +| 2026-10-16 | minor | PNG slide export with a transparent background (#784) | +| 2026-10-16 | trivial | A `.ppsx` opened from the command line or the file association starts the slide show (#862) | | 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) | | 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box | | 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain | diff --git a/docs/gaps.md b/docs/gaps.md index 4e564951..57271c4e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -180,7 +180,9 @@ Detail in [file-format-parity.md](file-format-parity.md). neither label. A combo chart's secondary value axis is read (`y2_*`, `Series.secondary_axis`) and is not written back when the chart is rebuilt. An unchanged chart's embedded workbook now survives save (#335); editing the chart still - drops it rather than writing a stale spreadsheet. + drops it rather than writing a stale spreadsheet. The workbook and other retained chart parts + also survive `.deckcraft` save/reopen (`crates/engine/tests/chart_native.rs`), with binary + payloads in bounded ZIP entries sharing the media load budget (`crates/format` tests). - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). @@ -212,11 +214,10 @@ of the effect gallery. Detail in [animation-parity.md](animation-parity.md). 8 counters): a click on a shape whose action is "play sound" (`ppaction://media`, 126 and 486 shapes in two decks) does nothing; triggers that start on `onStopAudio` or `begin` conditions are not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count) and do - not follow a shape that a motion path has moved; the PPTX writer turns every "on click" after the - first effect of a trigger into "after previous", so a trigger with several click steps saved from - DeckCraft plays all of them at once. -- **Impact:** games built around sound effects on buttons, or around a button that counts up with - repeated clicks, lose that part when they are saved from DeckCraft or played in it. + not follow a shape that a motion path has moved. Trigger sequences now keep their separate + click steps on PPTX save, including paragraph builds (`crates/pptx/tests/trigger_steps.rs`). +- **Impact:** games built around sound effects on buttons, audio-triggered effects or moving + clickable shapes do not play as authored. - **Estimate:** 4–8 h. ### Notes and Handout masters @@ -258,8 +259,8 @@ of the effect gallery. Detail in [animation-parity.md](animation-parity.md). 8 installed (always on Windows and the web) only a short list of common misspellings is found. `review.thesaurus` looks up the selected word (checklist row partial). AutoCorrect has PowerPoint's options (two initial capitals, sentence and day capitals, caps lock, fractions, an - editable replace list); no AutoFormat of ordinals or automatic bulleted lists, no undo of a - single correction. + editable replace list), and AutoFormat superscripts ordinals (1st) as you type; no automatic + bulleted lists, no undo of a single correction. - **Estimate:** 1–2 h (bundle openly licensed dictionaries); 2–4 h (AutoFormat as you type). ### Equations @@ -355,6 +356,10 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | +| 2026-10-15 | trivial | Native saves retain chart package parts and relationships, including charts in groups; edited chart data still does not export a stale source workbook. Chart editing gaps and scores remain unchanged | +| 2026-10-15 | trivial | PPTX trigger sequences keep later On Click steps instead of converting them to After Previous; mixed starts, independent buttons and paragraph builds have synthetic round-trip regressions | +| 2026-10-16 | trivial | AutoFormat superscripts ordinals (1st, 22nd) as you type, with an AutoCorrect option to turn it off; automatic bulleted lists remain | | 2026-10-14 | trivial | Fixed #718: remote MCP no longer accepts unrelated or buffered stale success; loopback regressions cover invalid envelopes, incomplete and oversized frames | | 2026-10-14 | minor | Community PR wave folded: one entry each for the soak tests (engine and UI), the PPTX corpus (seven owned corpus files), edit points / text warp / 3-D, proofing (Hunspell spelling, thesaurus, AutoCorrect), the smaller missing rows, dialogs (41 vs ~60; Print and Insert Object left) and pen/trackpad input; ranked rows 3, 4 and 10 de-duplicated (comment threads #70 and the pane timeline are done); the fixed command-palette entry (#79) removed | | 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 94928d8c..f83835d3 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -54,7 +54,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Application shell | Multiple open presentations | P1 | P | | Application shell | Preferences (autosave, units, defaults) | P2 | P | | Files | New blank presentation | P0 | D | -| Files | Native file format (open/save) | P0 | D | +| Files | Native file format (open/save; chart package parts retained, `crates/engine/tests/chart_native.rs`) | P0 | D | | Files | Open PowerPoint .pptx | P0 | D | | Files | Save as PowerPoint .pptx | P0 | D | | Files | Open/save .potx/.ppsx/.pptm variants (.potx/.ppsx open; saved with the .pptx content type; no .pptm) | P2 | P | @@ -206,7 +206,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Animations | Effect options, start (on click/with/after), duration, delay | P0 | D | | Animations | Animation pane (reorder, timing) | P0 | P | | Animations | By-paragraph text builds | P1 | D | -| Animations | Triggers | P2 | D | +| Animations | Triggers (separate click steps and paragraph builds round-trip; `crates/pptx/tests/trigger_steps.rs`) | P2 | D | | Animations | Animation Painter | P2 | D | | Animations | Preview in editor | P1 | D | | Slide show | Full-screen show from start / current | P0 | D | diff --git a/docs/roadmap.md b/docs/roadmap.md index caf868de..235b44ae 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: PPTX corpus, presenter and show controls, review navigation, starter decks, Grid Options and New Window, WordArt gallery and handles, pen pressure; beta items 9 and 10 narrowed; duplicated rows and headers folded; previous: minor, core-workflow alpha gate added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: PPTX corpus, presenter and show controls, review navigation, starter decks, Grid Options and New Window, WordArt gallery and handles, pen pressure; beta items 9 and 10 narrowed; duplicated rows and headers folded; previous: minor, core-workflow alpha gate added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -115,6 +115,9 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | +| 2026-10-15 | trivial | Native save/reopen retains imported chart workbooks and related parts; headless command tests cover a PPTX → native → PPTX cycle and stale-workbook rejection. Milestone scope and scores unchanged | +| 2026-10-15 | trivial | PPTX trigger click steps survive save, including by-paragraph builds. Interactive-deck gaps remain media actions, audio triggers and click-box geometry; milestone scope and feature scores unchanged | | 2026-10-14 | trivial | M13 reliability: remote MCP validates IDs and envelopes, caps replies and rejects incomplete frames; #718 | | 2026-10-14 | minor | Community PR wave folded: one Review gate row, one Current focus item 2, one beta row each for items 9 and 10 (timeline and timer controls are in), one after-beta paragraph, one M9 and M10 row, one corpus row in the 2026-10-07 history | | 2026-10-13 | trivial | Beta item 3: embedded fonts are in, except MicroType Express subsets | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index aa1893ac..e5a42d29 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -186,7 +186,7 @@ Features row of the dimension table keeps ~62% until the next full re-measure). | Masters and themes | 7 | 92% | ~70% | 8–14 | Notes/Handout master view polish, effect-scheme depth, Designer suggestions | [parity-checklist.md](parity-checklist.md) | | Shapes | 7 | 91% | ~75% | 8–14 | Edit points depth, WordArt depth (the Transform gallery and warp adjust handles are in) | [ui-parity.md](ui-parity.md) | | Format (fill, line, effects) | 6 | 83% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) | -| Arrange | 3 | 100% | ~90% | 1–2 | Align to slide vs selected as a menu choice, More Rotation Options | [ui-parity.md](ui-parity.md) | +| Arrange | 3 | 100% | ~90% | 1–2 | More Rotation Options | [ui-parity.md](ui-parity.md) | | Text | 15 | 83% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), true vertical CJK, dictionaries, AutoFormat as you type, thesaurus depth, equations (PR #55 open) | [localization-parity.md](localization-parity.md) | | Tables | 5 | 97% | ~75% | 3–6 | Draw table depth, borders and cell effects depth | [parity-checklist.md](parity-checklist.md) | | Charts | 7 | 65% | ~45% | 12–20 | Data grid editing, chart elements, the `chartex` types (waterfall, funnel, treemap, sunburst, histogram, box) | [parity-checklist.md](parity-checklist.md) | @@ -289,6 +289,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Arrange shortfall: Align to Slide and Align Selected Objects are in the Align menu. Percentages unchanged | | 2026-10-14 | minor | Community PR wave folded: one headline breadth row (84% over 191 features after `cargo xtask parity`; duplicate checklist rows removed), one row per feature area with the latest breadth and shortfalls, one Themes / Slide show / Views / Review / Automation bullet each (320 engine commands). SmartArt ready ~35% (Text Pane, 6e40335) moves the area mean to 62.8%; dimension and headline percentages unchanged | | 2026-10-13 | minor | Shapes shortfall "text warp" narrowed to adjust handles: the Transform gallery applies a preset | | 2026-10-14 | minor | Media remainder: Insert › Icons places Lucide stroke icons. 3-D models and stock content remain. Headline percentages unchanged; the checklist row stays missing | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cc97dae4..3b1fedf7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -55,7 +55,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Save As | Places, folder, file name, type, Save, More options | Type (PowerPoint, DeckCraft, PDF, Outline, PNG, JPEG) and Save, which opens the system Save dialog (web: Download). .pptx/.deckcraft take the new file; the others are copies (`file.saveAs`, `file.export`) | | Print | Print and Copies, Printer, Print All / Selection / Current / Custom Range, Slides box, layout (Full Page Slides, Notes Pages, Outline, Handouts 1–9), sides, collation, colour, Edit Header & Footer; page preview with pager and zoom | Print, Print All Slides / Current Slide / Custom Range, Slides box (`1-3, 5`), Full Page Slides / Notes Pages / Handouts 1–9, Print Hidden Slides, Frame Slides, Edit Header & Footer; preview with pager. DeckCraft prints through a PDF (`file.export`) opened in the system viewer, which has the printers (web: downloaded). No printer list, copies, sides or colour | | Share | Cloud sharing | Not offered | -| Export | Create PDF/XPS Document, Create a Video, Create an Animated GIF, Package Presentation for CD, Create Handouts, Change File Type | Create PDF Document, Create Handouts (a PDF), Change File Type. No video, GIF or CD package | +| Export | Create PDF/XPS Document, Create a Video, Create an Animated GIF, Package Presentation for CD, Create Handouts, Change File Type | Create PDF Document, Create Handouts (a PDF), Change File Type (PNG with an optional transparent background, #784). No video, GIF or CD package | | Close | Closes; asks Save / Don't Save / Cancel about unsaved changes | Same (`file.close` after the prompt); Ctrl+W asks too | | Account | User information, sign-in, Office Background and Office Theme, product information, About | Appearance (light/dark, `view.dark`), product information, About DeckCraft. No accounts to sign in to | | Options | Opens the PowerPoint Options dialog | Opens Preferences | @@ -165,6 +165,7 @@ consistency is open (#73, #56). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Export › Change File Type › PNG and the Export… dialog offer a transparent background (#784) | | 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing | | 2026-10-14 | minor | Community PR wave folded: one row each for Format Shape, Dialogs (41, listed from `dialogs.rs`), Animation Pane (timeline is in), window, Quick Access Toolbar, Format Background, rulers, image paste, context menus, exact size and edit points; estimates re-totalled (~26–46 h) | | 2026-10-13 | trivial | Eyedropper in the colour menus (#367) | diff --git a/packaging/common.sh b/packaging/common.sh index bcb36951..369a74a5 100644 --- a/packaging/common.sh +++ b/packaging/common.sh @@ -20,10 +20,11 @@ workspace_version() { /^\[/ { in_pkg = ($0 == "[workspace.package]") ; next } in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit } ' "$ROOT/Cargo.toml" + return } VERSION="${DECKCRAFT_VERSION:-$(workspace_version)}" -if [ -z "$VERSION" ]; then +if [[ -z "$VERSION" ]]; then echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2 exit 1 fi @@ -33,7 +34,7 @@ DIST="${DIST:-$ROOT/dist/release}" mkdir -p "$DIST" export DIST -if [ -z "${DECKCRAFT_BUILD_SHA:-}" ]; then +if [[ -z "${DECKCRAFT_BUILD_SHA:-}" ]]; then DECKCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" fi export DECKCRAFT_BUILD_SHA @@ -42,29 +43,33 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" # Emit a GitHub Actions warning (plain stderr outside Actions). warn() { - if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + return } # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do - if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi + if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" + return } # Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so # the package carries their licences as OFL-.txt. Nothing without CRAFT_FONTS_DIR. copy_font_licences() { local dest="$1" f - [ -n "${CRAFT_FONTS_DIR:-}" ] || return 0 + [[ -n "${CRAFT_FONTS_DIR:-}" ]] || return 0 for f in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do - if [ -f "$f" ]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi + if [[ -f "$f" ]]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi done } # Portable SHA-256 of a file (prints just the hash). sha256() { - if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi + local file="$1" + if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi + return } diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index a8c05497..80f3cf33 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -14,7 +14,7 @@ set -euo pipefail LINUX="$ROOT/packaging/linux" APP_ID=ai.storyteller.deckcraft -if [ "${1:-}" != "--skip-build" ]; then +if [[ "${1:-}" != "--skip-build" ]]; then (cd "$ROOT" && cargo build --release --locked -p deckcraft -p deckcraft-cli) fi @@ -22,6 +22,7 @@ ARCH="$(uname -m)" case "$ARCH" in amd64) ARCH=x86_64 ;; arm64) ARCH=aarch64 ;; + *) ;; esac BIN="$CARGO_TARGET_DIR/release" NAME="deckcraft-$VERSION-freebsd-$ARCH" diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 3097755c..3a5508d7 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -21,7 +21,7 @@ FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 TARBALL="" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --no-test) TEST=0; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; @@ -38,7 +38,7 @@ case "$ARCH" in esac BASENAME="deckcraft-$VERSION-linux-$ARCH" TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" -[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } +[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done @@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[ -x "$WORK/stage/bin/deckcraft" ] || { echo "error: $TARBALL has no bin/deckcraft" >&2; exit 1; } +[[ -x "$WORK/stage/bin/deckcraft" ]] || { echo "error: $TARBALL has no bin/deckcraft" >&2; exit 1; } cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" @@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak" flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" -if [ "$TEST" = 1 ]; then +if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" flatpak run --command=deckcraft-cli "$APP_ID" --version diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index eb0b7d0a..cddbdecc 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -21,7 +21,7 @@ APP_ID=ai.storyteller.deckcraft SKIP_BUILD=0 FORMATS="appimage deb rpm tar" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; @@ -42,8 +42,8 @@ BASENAME="deckcraft-$VERSION-linux-$ARCH" echo "==> DeckCraft $VERSION for Linux $ARCH ($FORMATS)" -if [ "$SKIP_BUILD" = 0 ]; then - if [ -n "${CROSS_TARGET:-}" ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then + if [[ -n "${CROSS_TARGET:-}" ]]; then (cd "$ROOT" && cargo build --release --locked -p deckcraft -p deckcraft-cli --target "$CROSS_TARGET") else (cd "$ROOT" && cargo build --release --locked -p deckcraft -p deckcraft-cli) @@ -75,7 +75,10 @@ if command -v appstreamcli >/dev/null; then appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" fi -has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } +has() { + local format="$1" + case " $FORMATS " in *" $format "*) return 0 ;; *) return 1 ;; esac +} # ---- .tar.gz ------------------------------------------------------------------------------------ if has tar; then @@ -108,10 +111,10 @@ if has appimage; then ln -s "$APP_ID.png" "$APPDIR/.DirIcon" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" - if [ -z "$TOOL" ]; then + if [[ -z "$TOOL" ]]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" + if [[ ! -x "$TOOL" ]]; then + curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi fi @@ -133,16 +136,16 @@ if has appimage; then # package) appimagetool also writes the .zsync, into its working directory, hence the cd. (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" - if [ -s "$OUT.zsync" ]; then + if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" else warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" fi fi -if [ -z "${CROSS_TARGET:-}" ]; then +if [[ -z "${CROSS_TARGET:-}" ]]; then "$STAGE/usr/bin/deckcraft-cli" --version -elif [ -n "${EMULATOR:-}" ]; then +elif [[ -n "${EMULATOR:-}" ]]; then "$EMULATOR" "$STAGE/usr/bin/deckcraft-cli" --version fi echo "==> done" diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 22afe13e..d668a561 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -45,6 +45,77 @@ NSHumanReadableCopyright Copyright © the DeckCraft authors. MIT OR Apache-2.0. + + CFBundleDocumentTypes + + + CFBundleTypeName + DeckCraft Presentation + CFBundleTypeRole + Editor + LSHandlerRank + Owner + CFBundleTypeIconFile + DeckCraft + LSItemContentTypes + + ai.storyteller.deckcraft.presentation + + + + CFBundleTypeName + PowerPoint Presentation + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + org.openxmlformats.presentationml.presentation + + CFBundleTypeExtensions + pptx + CFBundleTypeMIMETypes + application/vnd.openxmlformats-officedocument.presentationml.presentation + + + CFBundleTypeName + PowerPoint Template + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + org.openxmlformats.presentationml.template + + CFBundleTypeExtensions + potx + CFBundleTypeMIMETypes + application/vnd.openxmlformats-officedocument.presentationml.template + + + CFBundleTypeName + PowerPoint Slide Show + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + org.openxmlformats.presentationml.slideshow + + CFBundleTypeExtensions + ppsx + CFBundleTypeMIMETypes + application/vnd.openxmlformats-officedocument.presentationml.slideshow + + + UTExportedTypeDeclarations @@ -89,6 +160,40 @@ pptx + + UTTypeIdentifier + org.openxmlformats.presentationml.template + UTTypeDescription + Open XML Presentation Template + UTTypeConformsTo + + public.data + public.presentation + public.zip-archive + + UTTypeTagSpecification + + public.filename-extension + potx + + + + UTTypeIdentifier + org.openxmlformats.presentationml.slideshow + UTTypeDescription + Open XML Slide Show + UTTypeConformsTo + + public.data + public.presentation + public.zip-archive + + UTTypeTagSpecification + + public.filename-extension + ppsx + + diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index f27d3222..401cc301 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -12,7 +12,7 @@ umask 077 # shellcheck source=../common.sh . "$(dirname "${BASH_SOURCE[0]}")/../common.sh" -if [ -z "${APPLE_CERTIFICATE:-}" ]; then +if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized" exit 0 fi @@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')" -if [ -z "$IDENTITY" ]; then +if [[ -z "$IDENTITY" ]]; then security find-identity -v -p codesigning "$KEYCHAIN" >&2 echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2 exit 1 fi echo "Imported signing identity $IDENTITY" -if [ -n "${GITHUB_ENV:-}" ]; then +if [[ -n "${GITHUB_ENV:-}" ]]; then { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV" else echo "MACOS_SIGN_IDENTITY=$IDENTITY" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 4eed67ab..4d08d05c 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos" ARCH=universal SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; --skip-build) SKIP_BUILD=1; shift ;; @@ -44,9 +44,9 @@ DMG="$DIST/deckcraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/deckcraft-cli-$VERSION-macos-$ARCH.zip" NOTARIZE=0 -if [ "$IDENTITY" = "-" ]; then +if [[ "$IDENTITY" = "-" ]]; then warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized" -elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then +elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then NOTARIZE=1 else warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" @@ -55,7 +55,7 @@ fi echo "==> DeckCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" # ---- build ------------------------------------------------------------------------------------- -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p deckcraft -p deckcraft-cli "${args[@]}") @@ -74,9 +74,9 @@ done sign() { # Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped. local ts=(--timestamp) - [ "$IDENTITY" = "-" ] && ts=(--timestamp=none) + [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none) local kc=() - [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN") + [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN") codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } @@ -88,8 +88,8 @@ notarize() { echo "$out" id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)" status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)" - if [ "$status" != "Accepted" ]; then - if [ -n "$id" ]; then + if [[ "$status" != "Accepted" ]]; then + if [[ -n "$id" ]]; then xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true fi echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2 @@ -117,7 +117,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" codesign --verify --strict --deep --verbose=2 "$APP" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/DeckCraft-notarize.zip" notarize "$WORK/DeckCraft-notarize.zip" xcrun stapler staple "$APP" @@ -144,7 +144,7 @@ hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" rm -f "$WORK/raw.dmg" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" xcrun stapler staple "$DMG" xcrun stapler validate "$DMG" @@ -162,7 +162,7 @@ codesign --verify --strict --verbose=2 "$CLI_DIR/deckcraft-cli" rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" # A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online. -if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi +if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi "$WORK/bin/deckcraft-cli" --version echo "==> done" diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 3f16de19..6f939454 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -11,13 +11,13 @@ set -euo pipefail . "$(dirname "${BASH_SOURCE[0]}")/../common.sh" HERE="$ROOT/packaging/web" -if [ "${1:-}" != "--skip-build" ]; then +if [[ "${1:-}" != "--skip-build" ]]; then command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; } (cd "$ROOT/apps/deckcraft-web" && trunk build --release) fi SITE="$ROOT/dist/web" -[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } +[[ -f "$SITE/index.html" ]] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } # Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml). if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2 diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..5d2418cb --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_deckcraft +sonar.organization=storytold