Compare commits

..
14 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 84acb49895 Bump version to 0.4.0 (from 0.3.0) for release; refresh contributors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:02:02 -04:00
4b09f35c1d feat(text, fonts): UAX #9 BiDi and Arabic complex script typography engine (#4)
* Sprint 1: bidi and Arabic typography engine

fonts: ISO 15924 script and a:latin/a:ea/a:cs slot classification;
shape_range shapes an item with the whole paragraph as HarfRust
pre/post context, explicit script/language/direction, glyphs kept in
visual order; per-grapheme-cluster fallback (slot face, theme script
font, OS cascade families, bundled craft-fonts face); GDEF ligature
carets.

text: UAX #9 levels for the whole paragraph (a:pPr/@rtl), itemization
by level, script, face and shaping style (paint-only changes no longer
split words), logical line breaking at cluster boundaries, per-line
L1/L2 reordering (L4 mirroring by the shaper); RTL start edge and
bullets, justLow kashida; caret Affinity, logical/visual movement,
bidi hit testing and selection as the union of visual boxes.

model/pptx: theme a:font script fonts and algn="justLow" round-trip.
engine: format.rtl (Right-to-Left Text Direction), justifyLow align.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fonts: the bundled script face comes before installed fonts; no Microsoft family names

Arabic, Hebrew, Thai and Devanagari clusters try the craft-fonts face first, so every machine
draws the same, then openly licensed or OS-native installed families, then any installed face.
Installed Microsoft faces are still used through that scan, but not named (AGENTS.md §1.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tayeb <tayeb@192.168.11.179>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 10:48:26 -04:00
57545a99c8 Tables: rows grow to fit their text, cell text in the PDF text layer (#13) (#14)
* Tables: rows grow to fit their text, cell text in the PDF text layer (#13)

A stored row height (a:tr/@h) is a minimum, as in PowerPoint: each row grows so its cells' text fits. Tables from pandoc or python-pptx store h="0" and drew every row on top of each other. Cell editing (click to edit, cell outline) uses the same heights, and the PDF text layer now includes table cell text.

* Tables: a grown table's box covers its drawn rows; resize scales from drawn heights

Handles, hit tests and marquee used the stored frame, so rows grown to fit their text drew
below the selection box (a file with h=0 rows had a 0 pt tall box). Resizing divided by the
stored row total, which is ~0 for those files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:40:13 -04:00
alexanderalberandBrandon Thomas fa9a94a1c0 Morph: shapes move, resize and blend; Words and Characters options morph text (#5)
* Morph: shapes move, resize and blend in the slide show

Morph used to fall back to a plain crossfade in the show: morph_pairs and
morph_xfrm existed, but nothing rendered frames with them.

- anim: morph_frame builds a frame from two slides. Paired shapes move along
  morph_xfrm (eased); a pure fill colour change blends, other content changes
  cross-fade on the same moving box. Unpaired shapes fade out/in, shapes with
  entrance animations or hidden media stay out of the frame.
- render: render_blend draws such a frame in one pass: both backdrops
  (background, master and layout graphics) cross-fade, every shape resolves
  against the slide it comes from, so placeholders keep their own layout.
  Renderer::slide is split into canvas/fields/backdrop to share the code.
- ui-egui: the show renders morph frames with these, crossfade otherwise.

generated with Claude Code

* Contributors: add my own entry (alexanderalber)

generated with Claude Code

* Morph: Words and Characters options morph text token by token

The transition option (objects/words/characters) was stored and round-tripped
but ignored by the show. With Words or Characters, paired shapes whose text
differs now morph their text instead of cross-fading it:

- text: GlyphRun records per glyph the paragraph character it draws, so
  glyphs can be grouped into words or characters.
- anim: morph_frame reads the option and lists such pairs in MorphFrame::text.
- render: morph_text draws them. Shared words (or characters) travel from their
  place in the old text to their place in the new one and grow or shrink to the
  new size; a different look cross-fades on the way. The remaining tokens fade
  while following a scale and shift fitted to the shared ones, so a line moves
  and rearranges as one. Rotated text falls back to a cross-fade.
  draw_layout_with's glyph drawing moved into draw_glyph for reuse.
- ui-egui: the show passes the pairs to render_blend.

generated with Claude Code

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 10:32:41 -04:00
89d23ffa85 Default Windows startup to DirectX 12 (#16)
eframe's default wgpu instance enables PRIMARY | GL on Windows. Creating
the OpenGL backend can crash inside AMD's atio6axx.dll before the window
appears, so the app exits silently at launch. When WGPU_BACKEND is not
set, the Windows instance now enables only DirectX 12; an explicit
WGPU_BACKEND is still honoured. macOS, Linux and web are unchanged.

Same fix as storytold/designcraft#168 and storytold/filmcraft#179.


Claude-Session: https://claude.ai/code/session_01LyVbT6Vv4BwWZ4hY8973qD

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 10:30:12 -04:00
Gert Pellin - GPEandBrandon Thomas 80e8a0fe53 Desktop: write log records to standard error and a rotating log file (#7)
The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded: the crash guard's panic report, a failed
crash-recovery save, PowerPoint parts skipped on import, audio output
problems. A launch from a desktop menu has no terminal either, so there
was nothing to attach to a bug report.

apps/deckcraft/src/logging.rs (no new dependency):
- standard error plus <settings dir>/logs/deckcraft.log (beside ui.json
  and prefs.json); each launch rotates the previous log to
  deckcraft.1.log and .2.log, so the log of a run that crashed survives
  the next start; the file is capped at 16 MiB
- records logged before the settings directory is known are buffered
  (bounded) and written once the file is attached
- default levels: info for deckcraft*, warn for everything else;
  RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
  error as the only sink; a poisoned lock is tolerated
- a record holds the lock for one write of an already formatted line

The cpal error callback runs on the realtime audio thread (on ALSA in a
loop while the device stays broken), so it no longer logs: it keeps the
first error without blocking, and the UI thread logs it when the stream
stops.

The logger is installed first in main (before the engine's panic hook)
and the file is opened after argument parsing (--version leaves no
file). The settings directory moves out of prefs_path into config_dir.
The start-up eprintln!s (sample, files that fail to open, control
server) now go through the logger. Documented in README.md (Getting
started > Logs).

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 10:29:47 -04:00
MarekSpodBiedry ab6f511479 Start screen: theme tiles that wrap to a new row keep the left inset (#17) (#20) 2026-10-09 10:28:49 -04:00
b48870989c PPTX: recognise packages whose [Content_Types].xml is not in the first 4 KB (#19)
`sniff` searched only the leading 4096 bytes for the content-types part, but
OOXML writers may place that part anywhere in the archive. Recent PowerPoint
builds (16.x) store it as the last zip entry, so every deck saved by them was
rejected with "not a presentation DeckCraft can read" although it imports fine.
In one real folder 169 of 300 decks were affected.

Look the part up in the zip's central directory instead. The regression test
re-packs the sample deck with the part last behind 64 KB of padding and
checks that it is both recognised and imported.

Co-authored-by: Matthias Weber <webermatt@me.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 10:28:45 -04:00
alexanderalber b663be69b0 Slide show: a click after a long pause plays its step instead of skipping it (#11)
The step clock ran before the frame's input actions were applied. A click
that started a step (PlayStep) was timed against the old step_start (when the
slide was entered), so after waiting longer than the step's duration the step
finished in the same frame: everything appeared at once, without animation.
Input actions are now applied before the clock is read.

generated with Claude Code
2026-10-09 10:28:40 -04:00
Gert Pellin - GPE 34d5197370 README: install on Gentoo from the ::snakebyte overlay (#6)
* README: install on Gentoo from the ::snakebyte overlay

* Contributors: credit switch87 as Gert Pellin
2026-10-09 10:28:35 -04:00
Pedro Jose Soto Rivera 5fed177cbe Fix integer overflow on hostile H.264/HEVC parameter sets (#2)
Exp-Golomb values read with `read_ue()` can be as large as u32::MAX, so
`read_ue()? + N`, `log2_min + read_ue()?` and the cropping / conformance
window arithmetic overflowed: a panic in debug builds, and in release a
silent wrap (e.g. pic_width_in_mbs_minus1 = u32::MAX became a width of 0,
which passed the `<= 1024` check).

- bitstream: add `BitReader::read_ue_plus(n)`, an overflow-checked
  `ue(v) + n` that returns InvalidExpGolomb.
- h264/hevc: use it for every `read_ue()? + N`; saturate the log2 size
  sums (the existing range checks then reject them); validate the H.264
  crop in u64; make HEVC `crop_rect` saturating; validate the HEVC RPS
  delta range before casting to i32.
- Regression tests for each (they fail on the previous code).
2026-10-09 10:28:31 -04:00
Brandon Thomas baff6a2ff2 Merge pull request #8 from XusBadia/dmg-background
macOS DMG: branded window background and icon layout
2026-10-09 09:21:03 -04:00
68aff057bf CI: no macOS on PRs or main; macOS is built and tested by releases (#22)
The org has 5 macOS slots in total (GitHub Team), shared by every repo's CI and
release builds, and PR CI kept them full, so release builds queued behind it.
macOS now runs only in release.yml; a release that fails on macOS is fixed and
rebuilt.

Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:51:28 -04:00
Xus BadiaandClaude Opus 5.5 21f1703994 macOS DMG: branded window background and icon layout
The DMG opened as a plain Finder window. It now shows the app icon's figure on
the DeckCraft colour, with DeckCraft.app and the Applications link side by side.
Same approach as PhotoCraft's and VectorCraft's (storytold/photocraft#1201,
storytold/vectorcraft#493).

- packaging/macos/dmg/: background.svg (source; text outlined, no paper grain,
  the figure an <image> of assets/app-icon), background.tiff (1x + 2x HiDPI,
  16-colour palette, Deflate, sRGB; ~18 KB)
  and dmg-layout.DS_Store (Finder view settings: window, icon size 128,
  positions, background).
- generate.py writes the TIFF and the layout on any OS, byte-for-byte
  reproducible, with nothing from the machine that ran it (no paths, users,
  disk names or volume UUIDs).
- package.sh copies them into the staging folder; the image is still built with
  hdiutil makehybrid. Nothing extra runs in the signing job.
- The volume name drops the version: the layout finds the background through
  an alias that includes the volume name. The DMG file name keeps the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:43:52 +02:00
57 changed files with 4295 additions and 506 deletions
+3 -1
View File
@@ -23,7 +23,9 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-15]
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
# release.yml builds macOS (a failing release is fixed and rebuilt).
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
+2
View File
@@ -18,6 +18,8 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds |
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks |
| `assets/app-icon/deckcraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.deckcraft.png` | DeckCraft contributors | rendered by DeckCraft from `assets/app-icon/icon-source.deckcraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn |
| `packaging/macos/dmg/background.svg` | @XusBadia | DeckCraft macOS DMG window background, original work for this repository (2026-10-08), derived from the DeckCraft app icon `assets/app-icon/deckcraft-1024.png` (referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Finder window of the macOS DMG; `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
| `assets/app-icon/deckcraft.icns`, `assets/app-icon/deckcraft.ico` | DeckCraft contributors | generated from the PNGs above by `packaging/icons.sh` (sips/iconutil, `cargo xtask ico`) | MIT OR Apache-2.0 | macOS and Windows app icons |
| `assets/app-icon/icon-source.deckcraft` | DeckCraft contributors | made with `deckcraft-cli run` (see git history) | MIT OR Apache-2.0 | Source of the placeholder icon |
| `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots |
Generated
+28 -27
View File
@@ -866,7 +866,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"arboard",
"cpal",
@@ -885,7 +885,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -894,7 +894,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -903,14 +903,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"proptest",
]
[[package]]
name = "deckcraft-cli"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -919,14 +919,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-color",
"deckcraft-fonts",
@@ -948,7 +948,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -960,7 +960,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-model",
"serde",
@@ -971,7 +971,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"kurbo",
"serde",
@@ -979,7 +979,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -988,7 +988,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -997,7 +997,7 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-bitstream",
"thiserror 2.0.21",
@@ -1005,11 +1005,11 @@ dependencies = [
[[package]]
name = "deckcraft-matroska"
version = "0.3.0"
version = "0.4.0"
[[package]]
name = "deckcraft-mcp"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1019,7 +1019,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1037,7 +1037,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1048,15 +1048,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.3.0"
version = "0.4.0"
[[package]]
name = "deckcraft-opus"
version = "0.3.0"
version = "0.4.0"
[[package]]
name = "deckcraft-pdf"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-fonts",
"deckcraft-model",
@@ -1071,7 +1071,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1087,7 +1087,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-color",
"deckcraft-fonts",
@@ -1103,19 +1103,20 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-color",
"deckcraft-fonts",
"deckcraft-geom",
"deckcraft-model",
"unicode-bidi",
"unicode-linebreak",
"unicode-segmentation",
]
[[package]]
name = "deckcraft-ui-egui"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1137,7 +1138,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1146,7 +1147,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -5428,7 +5429,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.3.0"
version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+45
View File
@@ -107,6 +107,36 @@ Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts): clone i
(`../craft-fonts`) and local builds pick it up automatically; without it DeckCraft uses your system
fonts.
Each [GitHub release](https://github.com/storytold/deckcraft/releases) has ready-made builds, on Linux as
an AppImage, a `.deb`, an `.rpm` and a tarball. On Gentoo, the community [::snakebyte
overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as
`app-office/deckcraft-bin` (not maintained by the DeckCraft team):
```sh
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
emaint sync -r snakebyte
echo 'app-office/deckcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/deckcraft
emerge --ask app-office/deckcraft-bin
```
### Logs
The desktop app writes its `log` records to standard error and to `logs/deckcraft.log` in the
settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/deckcraft/logs/`, or
`$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
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.
| Variable | Effect |
|---|---|
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DeckCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,deckcraft_pptx=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`deckcraft*=debug`). |
The logger is `apps/deckcraft/src/logging.rs`; the realtime audio callback does not log. The web
app logs to the browser console instead.
## Agents, CLI and MCP
Every action in DeckCraft is a command with a stable id, so people, scripts and AI agents use the
@@ -140,6 +170,21 @@ scorecard.
Installers and executables are code-signed.
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
PowerShell, from the folder containing the executable:
```powershell
$env:WGPU_BACKEND = "vulkan"
& .\deckcraft.exe
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
```
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
### macOS
| Build | File | Notes |
+5
View File
@@ -17,6 +17,11 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av
basic SmartArt, groups, connectors, ink, action buttons, WordArt.
- Text: in-place editing with caret, selection, keyboard and mouse; every Home-tab font and paragraph
control; bullets and numbering; levels; autofit; columns; vertical text; fields; hyperlinks.
Right-to-left and Arabic text: UAX #9 bidi (levels per paragraph, L1/L2 per line, mirrored
brackets), contextual HarfRust shaping across style runs, `a:latin`/`a:ea`/`a:cs` slots with
per-cluster fallback through theme script fonts, RTL start edge and bullets, `justLow` kashida,
bidi caret/hit-testing/selection (`format.rtl`). Arrow keys still move logically (visual
movement with caret affinity lands with the keyboard sprint).
- Editing: select, marquee, move, resize, rotate, adjust; smart guides; nudge; duplicate; z-order;
group/ungroup; align/distribute; Format Painter; Selection pane; clipboard incl. images.
- Transitions (fade, push, wipe, split, cover, uncover, zoom, morph…) and animations (entrance,
+72 -1
View File
@@ -1,12 +1,50 @@
//! cpal audio output for media playback: the device pulls mixed samples from the
//! `deckcraft_media::Player`, which makes it the playback clock video frames follow.
use std::sync::{Arc, Mutex, PoisonError};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use deckcraft_media::AudioOut;
/// The first stream error since it was last reported. cpal calls the error callback on its
/// realtime audio thread (on ALSA in a loop while the device stays broken), which must not log:
/// the callback only keeps the first error, and the UI thread logs it when the stream stops.
type ErrorSlot = Arc<Mutex<Option<String>>>;
/// Keep `e` unless an error is already kept. Never blocks: a busy slot drops the error.
fn keep_stream_error(slot: &ErrorSlot, e: &dyn std::fmt::Display) {
if let Ok(mut kept) = slot.try_lock()
&& kept.is_none()
{
*kept = Some(e.to_string());
}
}
/// The kept error, once (a poisoned lock is tolerated).
fn take_stream_error(slot: &ErrorSlot) -> Option<String> {
slot.lock().unwrap_or_else(PoisonError::into_inner).take()
}
#[derive(Default)]
pub struct CpalOut {
stream: Option<cpal::Stream>,
error: ErrorSlot,
}
impl CpalOut {
/// Log the stream error the realtime callback kept (UI thread).
fn report(&self) {
if let Some(e) = take_stream_error(&self.error) {
log::warn!("audio stream error: {e}");
}
}
}
impl Drop for CpalOut {
fn drop(&mut self) {
self.stream = None;
self.report();
}
}
impl AudioOut for CpalOut {
@@ -17,7 +55,8 @@ impl AudioOut for CpalOut {
let channels = cfg.channels() as usize;
let rate = cfg.sample_rate().0;
let config: cpal::StreamConfig = cfg.clone().into();
let err = |e| log::warn!("audio stream error: {e}");
let slot = self.error.clone();
let err = move |e: cpal::StreamError| keep_stream_error(&slot, &e);
let stream = match cfg.sample_format() {
cpal::SampleFormat::F32 => dev.build_output_stream(&config, move |buf: &mut [f32], _| fill(buf, channels, rate), err, None),
cpal::SampleFormat::I16 => {
@@ -45,5 +84,37 @@ impl AudioOut for CpalOut {
fn stop(&mut self) {
self.stream = None;
self.report();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_first_stream_error_is_kept_and_it_is_taken_once() {
let slot = ErrorSlot::default();
assert_eq!(take_stream_error(&slot), None);
keep_stream_error(&slot, &"device unplugged");
keep_stream_error(&slot, &"device unplugged again");
assert_eq!(take_stream_error(&slot).as_deref(), Some("device unplugged"));
assert_eq!(take_stream_error(&slot), None);
keep_stream_error(&slot, &"a later error");
assert_eq!(take_stream_error(&slot).as_deref(), Some("a later error"));
}
#[test]
fn a_poisoned_slot_still_reports() {
let slot = ErrorSlot::default();
keep_stream_error(&slot, &"lost");
let s = slot.clone();
let _ = std::thread::spawn(move || {
let _g = s.lock();
panic!("poison the slot");
})
.join();
assert!(slot.is_poisoned());
assert_eq!(take_stream_error(&slot).as_deref(), Some("lost"));
}
}
+2 -2
View File
@@ -14,11 +14,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("deckcraft: control server failed to bind 127.0.0.1:{port}: {e}");
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
eprintln!("deckcraft: control server listening on 127.0.0.1:{port}");
log::info!("control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
+47
View File
@@ -0,0 +1,47 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes GL. Creating that backend can crash inside AMD's
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
// workaround without mutating process environment variables.
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn configured_backends(backend_override: Option<Backends>) -> Backends {
let mut options = NativeOptions::default();
configure(&mut options, backend_override);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
create.instance_descriptor.backends
}
#[test]
fn windows_default_initializes_only_dx12() {
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
assert_eq!(configured_backends(None), Backends::DX12);
}
#[test]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
assert_eq!(configured_backends(Some(backends)), backends);
}
}
}
+567
View File
@@ -0,0 +1,567 @@
//! The desktop app's logger: `log::` records go to standard error and to a log file.
//!
//! Without a logger every `log::warn!`/`log::error!` in the workspace (the crash guard's panic
//! report, a failed crash-recovery save, PowerPoint parts that were skipped on import, audio output
//! problems) vanished. A launch from a desktop menu has no terminal, so the file is what a bug report
//! can attach: `<settings dir>/logs/deckcraft.log`, beside `ui.json` and `prefs.json`. Each start
//! moves the previous log to `deckcraft.1.log` (and that one to `.2`), so the log of a run that
//! crashed survives the next launch.
//!
//! Levels: `info` for DeckCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
//! `warn,deckcraft_pptx=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
//! that starts with it (`deckcraft*=debug`).
//!
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
//! written once the file is attached. Writing never panics: a file that can't be created or written
//! leaves standard error as the only sink.
//!
//! Realtime audio: the cpal callbacks (`audio.rs`) do not log; a stream error is kept and logged
//! from the UI thread when the stream stops. A record takes the sink lock only for one `write_all`
//! of an already formatted line (standard error is written outside the lock).
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::SystemTime;
use log::LevelFilter;
/// The current log file's name inside the log directory.
pub const LOG_FILE: &str = "deckcraft.log";
/// The log directory inside the settings directory.
pub const LOG_DIR: &str = "logs";
/// How many previous logs are kept (`deckcraft.1.log` … `deckcraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The log file stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the log file is attached.
pub const MAX_PENDING: usize = 512;
/// The built-in filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,deckcraft*=info";
/// Per-target level filter parsed from env_logger-style directives.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Filter {
default: LevelFilter,
directives: Vec<(String, LevelFilter)>,
}
impl Filter {
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
/// directive names log errors only.
pub fn parse(spec: &str) -> Filter {
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match part.split_once('=') {
Some((name, level)) => {
let name = name.trim();
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
f.directives.push((name.to_owned(), level));
}
}
None => match parse_level(part) {
Some(level) => f.default = level,
// A bare target name: everything from it (env_logger does the same).
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
},
}
}
f
}
/// The level that applies to `target` (the most specific matching directive wins).
pub fn level_for(&self, target: &str) -> LevelFilter {
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
}
/// The most verbose level any target can reach (for `log::set_max_level`).
pub fn max(&self) -> LevelFilter {
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
}
}
fn parse_level(s: &str) -> Option<LevelFilter> {
s.trim().parse().ok()
}
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
/// `name*` matches every target starting with `name`.
fn matches(name: &str, target: &str) -> bool {
match name.strip_suffix('*') {
Some(prefix) => target.starts_with(prefix),
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
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())
}
/// 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)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("deckcraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
/// then appended to it until it reaches its size cap.
pub struct Sink {
file: Option<File>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
pub fn new(max_bytes: u64) -> Sink {
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
pub fn write(&mut self, line: &str) {
if self.file.is_none() {
if self.capped {
return;
}
if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file`, first the lines kept so far.
pub fn attach(&mut self, file: File) {
self.file = Some(file);
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
/// No file will come: drop the kept lines and keep none from now on.
pub fn close(&mut self) {
self.file = None;
self.pending = Vec::new();
self.capped = true;
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink (and
// stop buffering, since no file will come).
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
/// An unusable directory leaves standard error as the only sink ([`AppLogger::stderr_only`]).
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
match rotate(dir) {
Ok((path, file)) => {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
Ok(path)
}
Err(e) => {
self.stderr_only();
Err(format!("{}: {e}", dir.display()))
}
}
}
/// No log file this run (no settings directory, or it can't be used): stop keeping records for one.
pub fn stderr_only(&self) {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).close();
}
}
impl log::Log for AppLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.filter.level_for(metadata.target())
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
// Format before taking the lock, so the lock covers one write only.
let thread = std::thread::current();
let line =
format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
if self.stderr {
let _ = std::io::stderr().write_all(line.as_bytes());
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
}
fn flush(&self) {}
}
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
pub fn install() -> Option<&'static AppLogger> {
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
let max = filter.max();
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
log::set_logger(logger).ok()?;
log::set_max_level(max);
Some(logger)
}
#[cfg(test)]
mod tests {
use super::*;
use log::Log;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("deckcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &'static str) {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
}
#[test]
fn the_default_filter_shows_deckcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("deckcraft"), LevelFilter::Info);
assert_eq!(f.level_for("deckcraft::audio"), LevelFilter::Info);
assert_eq!(f.level_for("deckcraft_engine::guard"), LevelFilter::Info);
assert_eq!(f.level_for("deckcraft_pptx::read"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,deckcraft_pptx=trace,deckcraft_pptx::opc=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("deckcraft_pptx::read"), LevelFilter::Trace);
assert_eq!(f.level_for("deckcraft_pptx::opc"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("warn,deckcraft_ui*=debug").level_for("deckcraft_ui_egui::canvas"), LevelFilter::Debug);
assert_eq!(Filter::parse("warn,deckcraft_ui*=debug").level_for("deckcraft_engine"), LevelFilter::Warn);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive; the last bare level wins.
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "a".repeat(100_000);
let specs = [
"",
",,,",
"=",
"==",
"=debug",
"deckcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=debug",
"a=b=c",
"warn,,deckcraft=DEBUG",
" \t ",
"\u{0}",
"é*=trace",
&long,
];
for spec in specs {
let f = Filter::parse(spec);
for target in ["deckcraft", "", "🦀", "é", "a::b"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,deckcraft=DEBUG").level_for("deckcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
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");
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("deckcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("deckcraft.2.log")), "run 2");
assert!(!dir.join("deckcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
assert!(rotate(&dir.join("below")).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line(
"2026-10-08T07:59:17.728Z",
log::Level::Warn,
"main",
"deckcraft_pptx::opc",
"pptx: skipping oversized part ppt/media/image1.png"
),
"2026-10-08T07:59:17.728Z WARN [main] deckcraft_pptx::opc: pptx: skipping oversized part ppt/media/image1.png\n"
);
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_max_pending_lines_drop_nothing() {
let dir = temp_dir("exact");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
record(&logger, log::Level::Info, "deckcraft", "DeckCraft starting");
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(&logger, log::Level::Debug, "deckcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("deckcraft: DeckCraft starting")), "{text}");
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("deckcraft_engine::recovery").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// A second launch rotates: the first log becomes `.1`.
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
assert_eq!(read(&dir.join("deckcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
let dir = temp_dir("threads");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
std::thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
for _ in 0..100 {
record(&logger, log::Level::Info, "deckcraft", "from a worker");
}
});
}
});
let text = read(&path);
assert_eq!(text.lines().count(), 400);
assert!(text.lines().all(|l| l.ends_with("deckcraft: from a worker")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
let dir = temp_dir("unusable");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
assert!(e.contains(&dir.display().to_string()), "{e}");
record(&logger, log::Level::Error, "x", "still fine");
let sink = logger.sink.lock().expect("sink");
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
drop(sink);
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_poisoned_lock_does_not_stop_logging() {
let dir = temp_dir("poison");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = logger.sink.lock();
panic!("poison the sink");
}));
assert!(logger.sink.is_poisoned());
record(&logger, log::Level::Info, "deckcraft", "after the panic");
assert!(read(&path).contains("after the panic"));
let _ = std::fs::remove_dir_all(&dir);
}
}
+35 -6
View File
@@ -5,12 +5,18 @@
//! `--control <port>` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `deckcraft_ui_egui::control` for the methods.
//!
//! `log` records go to standard error and `<settings dir>/logs/deckcraft.log` (`RUST_LOG` sets the
//! levels); see [`logging`].
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod audio;
mod control_server;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
use deckcraft_engine::Session;
use deckcraft_ui_egui::{Services, SlideApp};
@@ -32,8 +38,9 @@ impl eframe::App for App {
}
}
fn prefs_path() -> Option<std::path::PathBuf> {
let base = if cfg!(target_os = "macos") {
/// The per-user settings directory: `ui.json`, `prefs.json` and `logs/` live here.
fn config_dir() -> Option<std::path::PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DeckCraft"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DeckCraft"))
@@ -42,8 +49,11 @@ fn prefs_path() -> Option<std::path::PathBuf> {
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join("deckcraft"))
};
base.map(|b| b.join("ui.json"))
}
}
fn prefs_path() -> Option<std::path::PathBuf> {
config_dir().map(|b| b.join("ui.json"))
}
fn load_prefs(app: &mut SlideApp) {
@@ -125,6 +135,9 @@ fn app_icon() -> Option<egui::IconData> {
}
fn main() -> eframe::Result {
// First, so every start-up record (and the engine's panic hook, installed with the first
// Session) is captured; see `logging`.
let logger = logging::install();
let mut control_port: Option<u16> = std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
@@ -142,6 +155,19 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
log::info!("DeckCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
// The log file lives under the settings directory; opened after the arguments, so `--version`
// leaves no file behind. Records logged until now are written to it first.
if let Some(logger) = logger {
match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
Some(Ok(path)) => log::info!("log file {}", path.display()),
Some(Err(e)) => log::warn!("no log file: {e}"),
None => {
logger.stderr_only();
log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)");
}
}
}
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("DeckCraft")
@@ -164,6 +190,9 @@ fn main() -> eframe::Result {
b.with_x11();
}));
}
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
eframe::run_native(
"DeckCraft",
options,
@@ -185,11 +214,11 @@ fn main() -> eframe::Result {
app = app.with_control(rx);
}
if sample && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
eprintln!("deckcraft: sample: {e}");
log::error!("sample: {e}");
}
for f in files {
if let Err(e) = app.open_path(&f) {
eprintln!("deckcraft: {f}: {e}");
log::error!("{f}: {e}");
}
}
if show {
+137 -11
View File
@@ -2,23 +2,140 @@
"schema": 1,
"repo": "storytold/deckcraft",
"branch": "main",
"head": "9da767d5f762119c9bad9deb94001de36fde01c9",
"head_date": "2026-10-08T00:23:52Z",
"commits": 22,
"head": "4b09f35c1d6fa50e2d94243a5d65d98fe49558e0",
"head_date": "2026-10-09T14:48:26Z",
"commits": 41,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 22,
"lines_added": 123461,
"lines_deleted": 3662,
"prs": 5,
"commits": 28,
"lines_added": 124410,
"lines_deleted": 3748,
"binary_added": 47,
"binary_deleted": 12,
"first_commit": "2026-10-06T02:13:02Z",
"last_commit": "2026-10-08T00:23:52Z"
"last_commit": "2026-10-09T12:51:28Z"
},
{
"login": "h-a-n-a-s-h-i",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 2,
"lines_added": 56,
"lines_deleted": 56,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:13:34Z",
"last_commit": "2026-10-08T14:02:34Z"
},
{
"login": "XusBadia",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 241,
"lines_deleted": 1,
"binary_added": 2,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:12:32Z",
"last_commit": "2026-10-08T13:12:32Z"
},
{
"login": "Pedroj-64",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 162,
"lines_deleted": 42,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:28:31Z",
"last_commit": "2026-10-09T14:28:31Z"
},
{
"login": "switch87",
"people_entry": true,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 706,
"lines_deleted": 9,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:28:35Z",
"last_commit": "2026-10-09T14:29:47Z"
},
{
"login": "alexanderalber",
"people_entry": true,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 718,
"lines_deleted": 53,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:28:40Z",
"last_commit": "2026-10-09T14:32:41Z"
},
{
"login": "wurstbrotdlx",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 65,
"lines_deleted": 1,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:28:45Z",
"last_commit": "2026-10-09T14:28:45Z"
},
{
"login": "MarekSpodBiedry",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 265,
"lines_deleted": 77,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:28:49Z",
"last_commit": "2026-10-09T14:40:13Z"
},
{
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 67,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:30:12Z",
"last_commit": "2026-10-09T14:30:12Z"
},
{
"login": "hamzaelkachtaf4-star",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1902,
"lines_deleted": 282,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T14:48:26Z",
"last_commit": "2026-10-09T14:48:26Z"
}
],
"models": [
@@ -27,9 +144,18 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 21,
"lines_added": 123454,
"lines_deleted": 3662
"commits": 33,
"lines_added": 126890,
"lines_deleted": 4162
},
{
"id": "anthropic/claude-fable/5.1",
"company": "Anthropic",
"model": "Claude Fable",
"version": "5.1",
"commits": 1,
"lines_added": 65,
"lines_deleted": 1
}
]
}
+8
View File
@@ -21,3 +21,11 @@
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.alexanderalber]
real_name = "Alexander Alber"
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
+3 -2
View File
@@ -3,7 +3,8 @@
//! - [`Timeline`]: a slide's animation list grouped into click steps, with per-shape (and
//! per-paragraph) [`AnimState`] at any point of the show.
//! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images.
//! - [`morph_pairs`] / [`morph_xfrm`]: Morph transition shape matching and interpolation.
//! - [`morph_frame`]: a Morph transition frame (shapes matched by [`morph_pairs`], moved by
//! [`morph_xfrm`]).
//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings).
//!
//! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or
@@ -26,7 +27,7 @@ pub use deckcraft_model::Rgba;
use serde::{Deserialize, Serialize};
pub use easing::ease;
pub use morph::{morph_pairs, morph_xfrm};
pub use morph::{MorphFrame, MorphText, morph_frame, morph_pairs, morph_xfrm};
pub use path::{MotionPath, default_path};
pub use show::{ShowAction, ShowState, show_order};
pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay};
+133 -2
View File
@@ -1,8 +1,10 @@
//! Morph: matching shapes between two slides and interpolating their boxes.
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
use deckcraft_model::{Shape, ShapeId, Slide, Xfrm};
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{ColorRef, Fill, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
use crate::clampf;
use crate::easing::smooth;
fn text_of(s: &Shape) -> String {
s.text.as_ref().map(|t| t.text()).unwrap_or_default()
@@ -91,3 +93,132 @@ pub fn morph_xfrm(a: Xfrm, b: Xfrm, t: f64) -> Xfrm {
flip_v: if late { b.flip_v } else { a.flip_v },
}
}
/// One frame of a Morph transition, ready for the renderer's `render_blend`: the backdrops of the
/// two slides cross-fade by [`MorphFrame::mix`], the shapes are drawn in order on top.
#[derive(Clone, Debug, Default)]
pub struct MorphFrame {
/// Shapes with fresh ids, each flagged `true` when it comes from the old slide (it resolves
/// its placeholder, theme and style against that slide).
pub shapes: Vec<(Shape, bool)>,
/// Opacity of the frame's shapes by id; shapes not listed are opaque.
pub opacity: Vec<(ShapeId, f64)>,
pub mix: f64,
/// With the Words or Characters option: pairs of frame shapes whose texts morph by words or
/// characters instead of cross-fading (the renderer's `TextMorph`).
pub text: Vec<MorphText>,
}
/// Two frame shapes whose texts morph by words or characters.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MorphText {
/// Frame shape ids: from the old slide, from the new slide.
pub old: ShapeId,
pub new: ShapeId,
/// The boxes of the two shapes on their own slides.
pub from: Xfrm,
pub to: Xfrm,
pub chars: bool,
}
impl MorphFrame {
/// Opacity of shape `id` of the frame.
pub fn opacity_of(&self, id: ShapeId) -> f64 {
self.opacity.iter().find(|(s, _)| *s == id).map(|(_, o)| *o).unwrap_or(1.0)
}
}
fn mix_color(a: Rgba, b: Rgba, t: f64) -> Rgba {
let m = |x: u8, y: u8| clampf(lerp(x as f64, y as f64, t), 0.0, 255.0, 0.0).round() as u8;
Rgba { r: m(a.r, b.r), g: m(a.g, b.g), b: m(a.b, b.b), a: m(a.a, b.a) }
}
fn solid_color(ctx: Option<&Ctx>, s: &Shape) -> Option<Rgba> {
let ctx = ctx?;
match resolve::fill(ctx, s) {
(Some(Fill::Solid { color }), ph) => Some(ctx.color(&color, ph)),
_ => None,
}
}
fn box_of(ctx: Option<&Ctx>, s: &Shape) -> Option<Xfrm> {
match ctx {
Some(c) => Some(resolve::xfrm(c, s)),
None => s.xfrm,
}
}
/// The content of a shape apart from what Morph interpolates (identity, box and fill).
fn content(s: &Shape) -> Shape {
Shape { id: ShapeId(0), name: String::new(), xfrm: None, fill: None, ..s.clone() }
}
/// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes
/// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the
/// colour blends, otherwise the new shape fades in under the fading old one on the same moving
/// box; with the transition option `words` or `characters` their texts are listed in
/// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for
/// which `hidden_b` is true (entrance animations, hidden media) stay out of the frame.
pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: &dyn Fn(ShapeId) -> bool) -> MorphFrame {
let s = smooth(clampf(t, 0.0, 1.0, 0.0));
let ca = Ctx::for_slide(pres, a);
let cb = Ctx::for_slide(pres, b);
let pairs: Vec<(ShapeId, ShapeId)> = morph_pairs(a, b).into_iter().filter(|(_, ib)| !hidden_b(*ib)).collect();
let mut next = 1u32;
walk(&a.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));
walk(&b.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));
let mut f = MorphFrame { mix: s, ..Default::default() };
let by = b.transition.as_ref().map(|tr| tr.option.as_str()).unwrap_or("");
let (by_text, chars) = (by == "words" || by == "characters", by == "characters");
let has_text = |x: &Shape| x.text.as_ref().is_some_and(|t| !t.is_empty()) && !matches!(x.kind, ShapeKind::Group { .. } | ShapeKind::Table(_));
let mut text = Vec::new();
let mut push = |mut sh: Shape, old: bool, op: f64| {
let id = ShapeId(next);
sh.id = id;
next = next.saturating_add(1);
if op < 1.0 {
f.opacity.push((id, op));
}
f.shapes.push((sh, old));
id
};
for sa in &a.shapes {
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
push(sa.clone(), true, 1.0 - s);
}
}
for sb in &b.shapes {
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
let Some(sa) = pair else {
if !hidden_b(sb.id) {
push(sb.clone(), false, s);
}
continue;
};
let x = match (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb)) {
(Some(xa), Some(xb)) => Some(morph_xfrm(xa, xb, s)),
(_, xb) => xb,
};
if content(sa) == content(sb) {
let mut m = Shape { xfrm: x, ..sb.clone() };
if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb))
&& fa != fb
{
m.fill = Some(Fill::solid(ColorRef::rgb(mix_color(fa, fb, s))));
}
push(m, false, 1.0);
} else {
let new = push(Shape { xfrm: x, ..sb.clone() }, false, s);
let old = push(Shape { xfrm: x, ..sa.clone() }, true, 1.0 - s);
if by_text
&& has_text(sa)
&& has_text(sb)
&& let (Some(from), Some(to)) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb))
{
text.push(MorphText { old, new, from, to, chars });
}
}
}
f.text = text;
f
}
+73
View File
@@ -543,6 +543,79 @@ fn morph_xfrm_interpolates() {
assert!(m.rot.is_finite());
}
fn solid(s: Shape, r: u8) -> Shape {
Shape { fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r, g: 0, b: 0, a: 255 }))), ..s }
}
#[test]
fn morph_frame_moves_blends_and_fades() {
let p = deckcraft_model::defaults::new_presentation(None);
let mut a = Slide::default();
let mut b = Slide::default();
let moved = |id, x| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), ..shape(id, "Box") };
a.shapes = vec![solid(moved(1, 0.0), 0), shape(2, "Gone")];
let new = Shape { text: Some(TextBody::from_text("Hi")), ..shape(3, "New") };
b.shapes = vec![solid(moved(1, 400.0), 200), new, shape(4, "Later")];
let f = morph_frame(&p, &a, &b, 0.5, &|id| id == ShapeId(4));
assert!((f.mix - 0.5).abs() < 1e-9);
// Gone fades out, Box moves halfway and blends red, New fades in, Later stays out.
let names: Vec<(&str, bool)> = f.shapes.iter().map(|(s, old)| (s.name.as_str(), *old)).collect();
assert_eq!(names, [("Gone", true), ("Box", false), ("New", false)]);
let m = &f.shapes[1].0;
assert!((m.xfrm.unwrap().x - 200.0).abs() < 1e-9);
assert_eq!(m.fill, Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r: 100, g: 0, b: 0, a: 255 }))));
assert_eq!(f.opacity_of(m.id), 1.0);
assert!((f.opacity_of(f.shapes[0].0.id) - 0.5).abs() < 1e-9);
assert!((f.opacity_of(f.shapes[2].0.id) - 0.5).abs() < 1e-9);
// Ids are fresh and unique.
let mut ids: Vec<_> = f.shapes.iter().map(|(s, _)| s.id).collect();
ids.dedup();
assert_eq!(ids.len(), 3);
assert!(ids.iter().all(|i| i.0 > 4));
// At the start only the old slide shows.
let f0 = morph_frame(&p, &a, &b, 0.0, &|id| id == ShapeId(4));
assert!(f0.shapes.iter().filter(|(s, _)| f0.opacity_of(s.id) > 0.0).all(|(s, _)| s.name != "New"));
assert_eq!(morph_frame(&p, &a, &b, f64::NAN, &|_| false).mix, 0.0);
}
#[test]
fn morph_frame_cross_fades_changed_content() {
let p = deckcraft_model::defaults::new_presentation(None);
let mut a = Slide::default();
let mut b = Slide::default();
a.shapes = vec![Shape { text: Some(TextBody::from_text("Before")), ..shape(1, "T") }];
b.shapes = vec![Shape { text: Some(TextBody::from_text("After")), ..shape(1, "T") }];
let f = morph_frame(&p, &a, &b, 0.25, &|_| false);
// The new text under the fading old one, both on the moving box.
assert_eq!(f.shapes.len(), 2);
assert_eq!((&f.shapes[0].0.text, f.shapes[0].1), (&b.shapes[0].text, false));
assert_eq!((&f.shapes[1].0.text, f.shapes[1].1), (&a.shapes[0].text, true));
let s = f.mix;
assert!((f.opacity_of(f.shapes[0].0.id) - s).abs() < 1e-9);
assert!((f.opacity_of(f.shapes[1].0.id) - (1.0 - s)).abs() < 1e-9);
}
#[test]
fn morph_frame_lists_texts_for_words_and_characters() {
let p = deckcraft_model::defaults::new_presentation(None);
let mut a = Slide::default();
let mut b = Slide::default();
a.shapes = vec![Shape { text: Some(TextBody::from_text("The Science")), ..shape(1, "T") }];
b.shapes = vec![Shape { text: Some(TextBody::from_text("Science rocks")), ..shape(1, "T") }];
for (option, want) in [("objects", None), ("words", Some(false)), ("characters", Some(true))] {
b.transition = Some(Transition { kind: "morph".into(), option: option.into(), ..Default::default() });
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
assert_eq!(f.text.first().map(|m| m.chars), want, "{option}");
if let Some(m) = f.text.first() {
assert_eq!((m.new, m.old), (f.shapes[0].0.id, f.shapes[1].0.id));
assert_eq!(m.from, a.shapes[0].xfrm.unwrap());
}
}
// Same text: one shape moves, nothing to morph.
b.shapes[0].text = a.shapes[0].text.clone();
assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).text.is_empty());
}
fn pres(n: usize) -> Presentation {
Presentation { slides: (0..n).map(|i| Arc::new(Slide { id: SlideId(1000 + i as u32), ..Default::default() })).collect(), ..Default::default() }
}
+1 -1
View File
@@ -199,7 +199,7 @@ fn scale_rect(r: R, s: f64) -> R {
/// Layers of the transition `kind` (a [`deckcraft_model::anim::TRANSITIONS`] id) with `option` at
/// progress `t` (0..1). Draw in order over black. At `t >= 1` the result is the new slide alone;
/// at `t <= 0` (or NaN) the old slide alone. Morph returns a plain crossfade (the UI renders morph
/// frames with [`crate::morph_pairs`]).
/// frames with [`crate::morph_frame`]).
pub fn transition_layers(kind: &str, option: &str, t: f64) -> Vec<Layer> {
let t = clampf(t, 0.0, 1.0, 0.0);
let v = if t >= 1.0 {
+18
View File
@@ -134,6 +134,12 @@ impl<'a> BitReader<'a> {
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
}
/// `ue(v) + add` (the `..._minus1` / `..._minus8` fields), failing instead of overflowing on a
/// hostile value near `u32::MAX`.
pub fn read_ue_plus(&mut self, add: u32) -> Result<u32> {
self.read_ue()?.checked_add(add).ok_or(BitError::InvalidExpGolomb)
}
/// Signed Exp-Golomb `se(v)`.
pub fn read_se(&mut self) -> Result<i32> {
let k = self.read_ue()? as i64;
@@ -343,6 +349,18 @@ mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn read_ue_plus_rejects_overflow() {
let mut w = BitWriter::new();
w.write_ue(7);
w.write_ue(u32::MAX);
w.rbsp_trailing();
let b = w.finish();
let mut r = BitReader::new(&b);
assert_eq!(r.read_ue_plus(1).unwrap(), 8);
assert!(r.read_ue_plus(1).is_err());
}
#[test]
fn reads_bits() {
let d = [0b1010_1100, 0b0101_0011];
+35 -1
View File
@@ -71,7 +71,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_or_shapes,
text_outline
),
cmd!("format.align", "Align", ["Home", "Paragraph"], None, "{align: left|center|right|justify|distributed}", has_text_or_shapes, align),
cmd!(
"format.align",
"Align",
["Home", "Paragraph"],
None,
"{align: left|center|right|justify|justifyLow|distributed}",
has_text_or_shapes,
align
),
cmd!(
"format.rtl",
"Right-to-Left Text Direction",
["Home", "Paragraph"],
None,
"{on?: bool} (off = left-to-right; switching mirrors left/right alignment)",
has_text_or_shapes,
rtl
),
cmd!("format.alignLeft", "Align Left", ["Home", "Paragraph"], Some("Cmd+L"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Left)),
cmd!("format.alignCenter", "Center", ["Home", "Paragraph"], Some("Cmd+E"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Center)),
cmd!("format.alignRight", "Align Right", ["Home", "Paragraph"], Some("Cmd+R"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Right)),
@@ -367,12 +384,29 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
"center" | "centre" | "ctr" => Align::Center,
"right" | "r" => Align::Right,
"justify" | "just" => Align::Justify,
"justifyLow" | "justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
_ => Align::Left,
};
align_to(s, a)
}
/// Paragraph reading direction. Switching it mirrors left and right alignment, so text that sat
/// at the start edge stays there.
fn rtl(s: &mut Session, p: &Value) -> Result<Value> {
let on = on_param(p, current_para(s).rtl);
format_para(s, &move |para| {
if para.props.rtl.unwrap_or(false) != on {
para.props.align = match para.props.align {
None | Some(Align::Left) => Some(Align::Right),
Some(Align::Right) => Some(Align::Left),
other => other,
};
}
para.props.rtl = Some(on);
})
}
fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. }));
let on = on_param(p, Some(cur));
+17 -4
View File
@@ -246,12 +246,25 @@ pub(crate) fn shape_of(s: &Session, id: ShapeId) -> Result<deckcraft_model::Shap
s.doc()?.shape(id).cloned().ok_or_else(|| EngineError::Other(format!("no shape {id}")))
}
/// Effective box of a shape (placeholders inherit theirs).
/// Effective box of a shape (placeholders inherit theirs). A table's box is at least as tall as its
/// rows are drawn (rows grow to fit their text), so handles and hit tests match what is shown.
pub fn xfrm_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: &deckcraft_model::Shape) -> Xfrm {
if let Some(x) = shape.xfrm {
return x;
let mut x = shape.xfrm.unwrap_or_else(|| ctx_xfrm(doc, sel, shape));
if let deckcraft_model::ShapeKind::Table(t) = &shape.kind {
let drawn: f64 = table_drawn_heights(doc, sel, t).iter().sum();
if drawn.is_finite() && drawn > x.h {
x.h = drawn;
}
}
x
}
/// Row heights of table `t` as drawn: a stored height is a minimum, rows grow to fit their text.
pub fn table_drawn_heights(doc: &deckcraft_model::Presentation, sel: &crate::Selection, t: &deckcraft_model::Table) -> Vec<f64> {
match text::ctx_for(doc, sel) {
Some(ctx) => deckcraft_render::table_row_heights(&ctx, t, &deckcraft_text::NoFields),
None => t.rows.iter().map(|r| r.height).collect(),
}
ctx_xfrm(doc, sel, shape)
}
fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: &deckcraft_model::Shape) -> Xfrm {
+59
View File
@@ -366,3 +366,62 @@ fn freeform_tools_and_command() {
g => panic!("{g:?}"),
}
}
#[test]
fn double_click_in_table_uses_grown_row_heights() {
// Rows stored 4 pt tall are drawn tall enough for their text, so 6 pt down is still row 0.
let mut s = session();
let data = json!([["a", "b"], ["c", "d"], ["e", "f"]]);
let id = s.execute("insert.table", &json!({"rows": 3, "cols": 2, "rect": [100.0, 100.0, 400.0, 120.0], "data": data})).unwrap()["id"].clone();
for row in 0..3 {
s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap();
}
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
s.pointer(ev(PointerKind::DoubleClick, 120.0, 106.0)).unwrap();
assert_eq!(s.doc().unwrap().selection.text.as_ref().and_then(|t| t.cell), Some((0, 0)));
}
#[test]
fn grown_table_frame_covers_its_drawn_rows() {
// Rows stored 4 pt tall are drawn taller: the box, its handles and hit tests follow the drawn rows.
let mut s = session();
let data = json!([["a", "b"], ["c", "d"], ["e", "f"]]);
let id = s.execute("insert.table", &json!({"rows": 3, "cols": 2, "rect": [100.0, 100.0, 400.0, 120.0], "data": data})).unwrap()["id"].clone();
for row in 0..3 {
s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap();
}
let st = s.doc().unwrap();
let sh = st.shapes().iter().find(|sh| json!(sh.id) == id).unwrap().clone();
let deckcraft_model::ShapeKind::Table(t) = &sh.kind else { panic!("not a table") };
let drawn: f64 = tools::table_rows(st, t).iter().sum();
assert!(drawn > 12.0, "rows should grow: {drawn}");
let x = cmd::xfrm_of(&st.doc, &st.selection, &sh);
assert!((x.h - drawn).abs() < 1e-6, "frame {} vs drawn {drawn}", x.h);
let below_stored = deckcraft_geom::Point::new(120.0, 100.0 + drawn - 2.0);
assert!(matches!(s.hit_test(below_stored, 1.0), Some(tools::Hit::Shape { .. })));
}
#[test]
fn right_to_left_paragraphs() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "سنة 2024 (Retrait) فقط"})).unwrap();
s.execute("format.rtl", &json!({})).unwrap();
let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone();
assert_eq!(para(&s).rtl, Some(true));
// Left (the default) becomes right: the text stays at its start edge.
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::Right));
s.execute("format.align", &json!({"align": "justifyLow"})).unwrap();
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::JustLow));
// Toggling back keeps a justified alignment.
s.execute("format.rtl", &json!({})).unwrap();
assert_eq!(para(&s).rtl, Some(false));
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::JustLow));
s.execute("format.rtl", &json!({"on": true})).unwrap();
s.execute("format.alignLeft", &json!({})).unwrap();
s.execute("format.rtl", &json!({"on": false})).unwrap();
assert_eq!(para(&s).align, Some(deckcraft_model::text::Align::Right));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(para(&s).rtl, Some(true));
}
+27 -10
View File
@@ -789,7 +789,7 @@ impl Session {
let x = xfrm_of(&st.doc, &st.selection, &sh);
let local = x.affine().inverse() * p;
if let ShapeKind::Table(t) = &sh.kind {
let cell = deckcraft_render_cell(t, local);
let cell = deckcraft_render_cell(t, &table_rows(st, t), local);
return self.execute("text.edit", &json!({"id": id, "cell": [cell.0, cell.1]}));
}
Ok(Value::Null)
@@ -855,16 +855,28 @@ impl Session {
}
fn set_xfrm(&mut self, id: ShapeId, x: Xfrm) -> Result<()> {
// Rows scale from their drawn heights: a stored height is only a minimum (often 0).
let drawn = self.active().and_then(|st| match &st.shape(id)?.kind {
ShapeKind::Table(t) => Some(table_rows(st, t)),
_ => None,
});
self.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("tool", "no slide"))?;
if let Some(sh) = deckcraft_model::find_shape_mut(list, id) {
sh.xfrm = Some(x);
if let ShapeKind::Table(t) = &mut sh.kind {
// Tables resize their columns and rows proportionally.
let (tw, th) = (t.width().max(1e-6), t.height().max(1e-6));
let (kx, ky) = (x.w / tw, x.h / th);
let heights: Vec<f64> = match &drawn {
Some(d) if d.len() == t.rows.len() => d.clone(),
_ => t.rows.iter().map(|r| r.height).collect(),
};
let (tw, th) = (t.width().max(1e-6), heights.iter().map(|h| h.max(0.0)).sum::<f64>());
let kx = x.w / tw;
t.cols.iter_mut().for_each(|c| *c *= kx);
t.rows.iter_mut().for_each(|r| r.height *= ky);
if th > 1e-6 {
let ky = x.h / th;
t.rows.iter_mut().zip(&heights).for_each(|(r, h)| r.height = h.max(0.0) * ky);
}
}
}
Ok(())
@@ -1257,16 +1269,21 @@ pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) {
}
}
/// Which table cell contains a table-local point.
fn deckcraft_render_cell(t: &deckcraft_model::Table, p: Point) -> (usize, usize) {
/// Row heights of table `t` as drawn: rows grow to fit their text.
pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec<f64> {
cmd::table_drawn_heights(&st.doc, &st.selection, t)
}
/// Which table cell contains a table-local point (`heights` from [`table_rows`]).
fn deckcraft_render_cell(t: &deckcraft_model::Table, heights: &[f64], p: Point) -> (usize, usize) {
let mut y = 0.0;
let mut row = t.rows.len().saturating_sub(1);
for (i, r) in t.rows.iter().enumerate() {
if p.y < y + r.height {
let mut row = heights.len().saturating_sub(1);
for (i, h) in heights.iter().enumerate() {
if p.y < y + h {
row = i;
break;
}
y += r.height;
y += h;
}
let mut x = 0.0;
let mut col = t.cols.len().saturating_sub(1);
+183
View File
@@ -0,0 +1,183 @@
//! Font fallback per grapheme cluster, and ligature caret positions.
//!
//! The cascade for a cluster (a base character with its marks, never split across faces) is:
//! 1. the faces the document asks for, in order (the run's slot typeface, then the theme's script
//! font, `a:font[@script="Arab"]`);
//! 2. for scripts with a platform cascade (Arabic, Hebrew, Thai, Devanagari): the craft-fonts face
//! bundled for the script (Noto Naskh Arabic for Arabic), so every machine draws the same; then
//! openly licensed or OS-native families found by name among the installed fonts (no platform
//! API is called); then any installed face covering the cluster;
//! 3. any loaded face (bundled first: Japanese keeps its craft-fonts faces), then the first
//! requested face (drawn as .notdef).
use std::sync::Arc;
use skrifa::raw::TableProvider;
use crate::script::{ScriptTag, script_of};
use crate::{CRAFT_FONTS, FontDb, FontFace};
/// Characters that never decide a face: they're drawn by whatever face the cluster gets.
fn ignorable(c: char) -> bool {
c.is_whitespace()
|| c.is_control()
|| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF)
}
/// Does `face` draw every character of the cluster?
pub fn covers_cluster(face: &FontFace, cluster: &[char]) -> bool {
cluster.iter().all(|c| ignorable(*c) || face.covers(*c))
}
/// Installed families tried by name for a script after the bundled face, before scanning every
/// installed font. Microsoft font names are not listed (AGENTS.md §1.1); an installed one is
/// still used by the scan when nothing earlier covers the cluster.
fn os_families(script: ScriptTag) -> &'static [&'static str] {
match &script {
b"Arab" => &["Noto Naskh Arabic", "Noto Sans Arabic", "Geeza Pro", "SF Arabic", "DejaVu Sans"],
b"Hebr" => &["Noto Sans Hebrew", "Arial Hebrew", "SF Hebrew", "DejaVu Sans"],
b"Thai" => &["Noto Sans Thai", "Thonburi"],
b"Deva" => &["Noto Sans Devanagari", "Kohinoor Devanagari", "Devanagari Sangam MN"],
_ => &[],
}
}
/// The craft-fonts families for a script (manifest order), e.g. Noto Naskh Arabic for `Arab`.
pub fn bundled_families(script: ScriptTag) -> Vec<&'static str> {
let tag = std::str::from_utf8(&script).unwrap_or("");
let mut v: Vec<&'static str> = CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&tag)).map(|f| f.family).collect();
v.dedup();
v
}
impl FontDb {
/// The face for one grapheme cluster: the first of `chain` that draws all of it, else the
/// bundled face for its script, else an installed face for the script, else any loaded
/// face that draws it, else `chain[0]` (or the default face if `chain` is empty).
pub fn cascade(&self, cluster: &[char], chain: &[Arc<FontFace>], style: &str) -> Arc<FontFace> {
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
return f.clone();
}
let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };
let script = cluster.iter().find_map(|c| script_of(*c));
// Scripts with a platform cascade (Arabic, Hebrew, Thai, Devanagari): the bundled face,
// then installed ones; the others keep the loaded-faces-first order (Japanese documents
// prefer the bundled craft-fonts faces).
if let Some(s) = script.filter(|s| !os_families(*s).is_empty()) {
let bundled = bundled_families(s);
for fam in &bundled {
let f = self.face(fam, style);
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
return f;
}
}
for fam in os_families(s) {
if bundled.contains(fam) {
continue;
}
if self.is_loaded(fam) || self.has_family(fam) {
let f = self.face(fam, style);
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
return f;
}
}
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(f) = self.system_face_for(key, cluster, &bundled) {
return f;
}
}
let exclude = chain.first().map(|f| f.id()).unwrap_or(u32::MAX);
match self.fallback_for(key, exclude) {
Some(f) if covers_cluster(&f, cluster) => f,
Some(f) if chain.iter().all(|c| !c.covers(key)) => f,
_ => first(),
}
}
/// An installed (non-bundled) face covering the cluster, loading one from the system catalog
/// if no loaded face does.
#[cfg(not(target_arch = "wasm32"))]
fn system_face_for(&self, key: char, cluster: &[char], bundled: &[&str]) -> Option<Arc<FontFace>> {
let pick = |db: &FontDb| {
let faces = db.read_faces();
let mut v: Vec<&Arc<FontFace>> = faces
.iter()
.filter(|f| !bundled.iter().any(|b| f.family.eq_ignore_ascii_case(b)) && !CRAFT_FONTS.iter().any(|c| c.family == f.family))
.filter(|f| covers_cluster(f, cluster))
.collect();
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
v.first().map(|f| (*f).clone())
};
if let Some(f) = pick(self) {
return Some(f);
}
if self.system_fallback(key) { pick(self) } else { None }
}
}
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
let Some(font) = face.skrifa() else { return Vec::new() };
let Ok(gdef) = font.gdef() else { return Vec::new() };
let Some(Ok(list)) = gdef.lig_caret_list() else { return Vec::new() };
let Ok(cov) = list.coverage() else { return Vec::new() };
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
let mut out: Vec<i32> = lig
.caret_values()
.iter()
.filter_map(|cv| cv.ok())
.filter_map(|cv| match cv {
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
skrifa::raw::tables::gdef::CaretValue::Format3(f) => Some(f.coordinate() as i32),
// Format 2 is a contour point index; outlines aren't consulted here.
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
})
.take(64)
.collect();
out.sort_unstable();
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requested_face_wins_when_it_covers() {
let db = FontDb::global();
let inter = db.face(crate::DEFAULT_FAMILY, "Regular");
let f = db.cascade(&['a'], std::slice::from_ref(&inter), "Regular");
assert!(Arc::ptr_eq(&f, &inter));
// Spaces and joiners never force a fallback.
let f = db.cascade(&[' ', '\u{200D}'], std::slice::from_ref(&inter), "Regular");
assert!(Arc::ptr_eq(&f, &inter));
// Nothing to go on: the default face.
assert!(!db.cascade(&[], &[], "Regular").family.is_empty());
}
#[test]
fn arabic_cluster_falls_back_to_an_arabic_face() {
let db = FontDb::global();
let inter = db.face(crate::DEFAULT_FAMILY, "Regular");
let cluster = ['\u{0628}', '\u{064E}']; // beh + fatha: one cluster, one face.
let f = db.cascade(&cluster, std::slice::from_ref(&inter), "Regular");
if covers_cluster(&f, &cluster) {
assert!(f.covers('\u{0628}'));
} else {
eprintln!("skipped: no installed or bundled face covers Arabic");
}
}
#[test]
fn ligature_carets_never_fail() {
let face = FontDb::global().face(crate::DEFAULT_FAMILY, "Regular");
for gid in [0, 1, 100, u32::MAX] {
let c = ligature_carets(&face, gid);
assert!(c.windows(2).all(|w| w[0] <= w[1]));
}
}
}
+3 -3
View File
@@ -557,7 +557,7 @@ impl FontDb {
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
pub(crate) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
self.faces.read().unwrap_or_else(|e| e.into_inner())
}
@@ -744,7 +744,7 @@ impl FontDb {
false
}
fn is_loaded(&self, family: &str) -> bool {
pub(crate) fn is_loaded(&self, family: &str) -> bool {
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
}
@@ -842,7 +842,7 @@ impl FontDb {
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
/// file under 40 MB). Returns true if one was loaded. Misses are remembered.
#[cfg(not(target_arch = "wasm32"))]
fn system_fallback(&self, c: char) -> bool {
pub(crate) fn system_fallback(&self, c: char) -> bool {
if c.is_control() || c.is_whitespace() {
return false;
}
+30 -83
View File
@@ -1,21 +1,25 @@
//! DeckCraft fonts: the font database (craft-fonts families, user and system fonts, and open
//! substitutes for the Office fonts presentations ask for), vertical metrics, glyph outlines and
//! OpenType shaping.
//! OpenType shaping, script/slot classification and per-cluster fallback.
//!
//! Shaping here is style-agnostic: [`shape`] turns a string in one face into glyph ids, clusters
//! and advances in font units. `deckcraft-text` applies sizes, tracking, scaling and
//! Shaping here is style-agnostic: [`shape`] and [`shape_range`] turn text in one face into glyph
//! ids, clusters and advances in font units. `deckcraft-text` applies sizes, tracking, scaling and
//! justification on top.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod fallback;
mod fontdb;
pub mod script;
mod shape;
pub use fallback::{bundled_families, covers_cluster, ligature_carets};
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs};
pub use harfrust::Feature;
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
use harfrust::Tag;
pub use kurbo::BezPath;
use skrifa::MetadataProvider;
use skrifa::instance::Size;
pub use script::{FontSlot, ScriptTag};
pub use shape::{ShapeParams, ShapedGlyph, shape_range};
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
@@ -53,17 +57,6 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
/// The default theme's font.
pub const DEFAULT_FAMILY: &str = "Inter";
/// One shaped glyph, in font units.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShapedGlyph {
pub gid: u32,
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
pub cluster: usize,
pub x_advance: i32,
pub x_offset: i32,
pub y_offset: i32,
}
/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`.
pub fn feature(tag: &str) -> Option<Feature> {
let (on, t) = match tag.strip_prefix('-') {
@@ -83,6 +76,26 @@ pub fn is_rtl(c: char) -> bool {
matches!(unicode_bidi::bidi_class(c), R | AL)
}
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
/// order: each direction run is shaped with [`shape_range`] (right-to-left runs right to left,
/// with the whole string as context), then its clusters put back in text order (each cluster's
/// glyphs keep the shaper's order). Paragraph layout uses [`shape_range`] directly and keeps the
/// visual order.
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
let mut out = Vec::with_capacity(text.len());
for (r, rtl) in direction_runs(text) {
let mut g = shape_range(face, text, r, &ShapeParams { features, rtl, ..Default::default() }, &map);
if rtl {
// Visual (clusters descending) → logical; the sort is stable, so each cluster's
// glyphs keep the shaper's order.
g.sort_by_key(|x| x.cluster);
}
out.extend(g);
}
out
}
/// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left).
fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
let mut runs: Vec<(std::ops::Range<usize>, bool)> = Vec::new();
@@ -110,72 +123,6 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
runs
}
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
if !text.chars().any(is_rtl) {
return shape_dir(face, text, features, &map, false);
}
let mut out = Vec::with_capacity(text.len());
for (r, rtl) in direction_runs(text) {
let mut g = shape_dir(face, &text[r.clone()], features, &map, rtl);
for x in &mut g {
x.cluster += r.start;
}
if rtl {
// Visual (clusters descending) → logical, cluster by cluster.
let mut groups: Vec<Vec<ShapedGlyph>> = Vec::new();
for x in g {
match groups.last_mut() {
Some(last) if last[0].cluster == x.cluster => last.push(x),
_ => groups.push(vec![x]),
}
}
groups.sort_by_key(|grp| grp[0].cluster);
g = groups.into_iter().flatten().collect();
}
out.extend(g);
}
out
}
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
let mut out = Vec::with_capacity(text.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
for (i, c) in text.char_indices() {
buf.add(map(c), i as u32);
}
buf.guess_segment_properties();
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
});
}
});
if shaped.is_none()
&& let Some(f) = face.skrifa()
{
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), face.location());
for (i, c) in text.char_indices() {
let g = cmap.map(map(c)).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
out.push(ShapedGlyph { gid: g.to_u32(), cluster: i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
}
}
out
}
/// Glyph id of the first of `chars` the face has (0 = .notdef).
pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0)
+246
View File
@@ -0,0 +1,246 @@
//! Script and font-slot classification.
//!
//! DrawingML gives every run three typefaces: `a:latin`, `a:ea` (East Asian) and `a:cs` (complex
//! script: Arabic, Hebrew, Thai, Indic…). Which one a character uses follows its Unicode script
//! (UAX #24). Characters with no script of their own (Common and Inherited: spaces, digits,
//! punctuation, combining marks) take the script and slot of the text around them, so a space or
//! a parenthesis between two Arabic words is shaped with them.
/// An ISO 15924 script tag, e.g. `*b"Arab"`.
pub type ScriptTag = [u8; 4];
pub const ARABIC: ScriptTag = *b"Arab";
pub const HEBREW: ScriptTag = *b"Hebr";
pub const LATIN: ScriptTag = *b"Latn";
/// The DrawingML typeface slot a character is drawn with.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum FontSlot {
/// `a:latin`.
#[default]
Latin,
/// `a:ea`.
EastAsian,
/// `a:cs`.
ComplexScript,
}
/// Inclusive code point ranges and their script (a subset of `Scripts.txt` covering the scripts
/// the slot decision and shaping need; everything else is Latin-slot or Common).
const RANGES: &[(u32, u32, ScriptTag)] = &[
(0x0041, 0x005A, *b"Latn"),
(0x0061, 0x007A, *b"Latn"),
(0x00AA, 0x00AA, *b"Latn"),
(0x00BA, 0x00BA, *b"Latn"),
(0x00C0, 0x00D6, *b"Latn"),
(0x00D8, 0x00F6, *b"Latn"),
(0x00F8, 0x024F, *b"Latn"),
(0x0370, 0x03FF, *b"Grek"),
(0x0400, 0x052F, *b"Cyrl"),
(0x0530, 0x058F, *b"Armn"),
(0x0591, 0x05F4, *b"Hebr"),
// Arabic block, minus the Common/Inherited code points handled in `script_of`.
(0x0600, 0x06FF, *b"Arab"),
(0x0700, 0x074F, *b"Syrc"),
(0x0750, 0x077F, *b"Arab"),
(0x0780, 0x07BF, *b"Thaa"),
(0x07C0, 0x07FF, *b"Nkoo"),
(0x0870, 0x08FF, *b"Arab"),
(0x0900, 0x097F, *b"Deva"),
(0x0980, 0x09FF, *b"Beng"),
(0x0A00, 0x0A7F, *b"Guru"),
(0x0A80, 0x0AFF, *b"Gujr"),
(0x0B00, 0x0B7F, *b"Orya"),
(0x0B80, 0x0BFF, *b"Taml"),
(0x0C00, 0x0C7F, *b"Telu"),
(0x0C80, 0x0CFF, *b"Knda"),
(0x0D00, 0x0D7F, *b"Mlym"),
(0x0D80, 0x0DFF, *b"Sinh"),
(0x0E00, 0x0E7F, *b"Thai"),
(0x0E80, 0x0EFF, *b"Laoo"),
(0x0F00, 0x0FFF, *b"Tibt"),
(0x1000, 0x109F, *b"Mymr"),
(0x10A0, 0x10FF, *b"Geor"),
(0x1100, 0x11FF, *b"Hang"),
(0x1200, 0x139F, *b"Ethi"),
(0x1780, 0x17FF, *b"Khmr"),
(0x1E00, 0x1EFF, *b"Latn"),
(0x1F00, 0x1FFF, *b"Grek"),
(0x2C60, 0x2C7F, *b"Latn"),
(0x2E80, 0x2FDF, *b"Hani"),
(0x3040, 0x309F, *b"Hira"),
(0x30A0, 0x30FF, *b"Kana"),
(0x3100, 0x312F, *b"Bopo"),
(0x3130, 0x318F, *b"Hang"),
(0x31F0, 0x31FF, *b"Kana"),
(0x3400, 0x4DBF, *b"Hani"),
(0x4E00, 0x9FFF, *b"Hani"),
(0xA720, 0xA7FF, *b"Latn"),
(0xAC00, 0xD7AF, *b"Hang"),
(0xF900, 0xFAFF, *b"Hani"),
(0xFB00, 0xFB06, *b"Latn"),
(0xFB1D, 0xFB4F, *b"Hebr"),
(0xFB50, 0xFDFF, *b"Arab"),
(0xFE70, 0xFEFF, *b"Arab"),
(0xFF21, 0xFF3A, *b"Latn"),
(0xFF41, 0xFF5A, *b"Latn"),
(0xFF66, 0xFF9F, *b"Kana"),
(0x10E60, 0x10E7F, *b"Arab"),
(0x1EE00, 0x1EEFF, *b"Arab"),
(0x20000, 0x323AF, *b"Hani"),
];
/// Code points inside the Arabic blocks whose script is Common or Inherited: the Arabic comma,
/// semicolon and question mark, tatweel, the harakat (combining vowel marks) and the BOM-like
/// U+FEFF. They take the script of the text around them.
fn arabic_common(cp: u32) -> bool {
matches!(cp, 0x060C | 0x061B | 0x061F | 0x0640 | 0x064B..=0x0655 | 0x0670 | 0xFEFF | 0xFD3E | 0xFD3F)
}
/// The script of `c`, or `None` for Common and Inherited characters (spaces, digits,
/// punctuation, combining marks), which take the script of their neighbours.
pub fn script_of(c: char) -> Option<ScriptTag> {
let cp = c as u32;
if arabic_common(cp) {
return None;
}
// Ranges are sorted and disjoint: binary search on the start.
let i = RANGES.partition_point(|(lo, _, _)| *lo <= cp);
let (lo, hi, tag) = RANGES.get(i.checked_sub(1)?)?;
(cp >= *lo && cp <= *hi).then_some(*tag)
}
/// The slot a script is drawn with.
pub fn slot_of_script(s: ScriptTag) -> FontSlot {
match &s {
b"Hani" | b"Hira" | b"Kana" | b"Hang" | b"Bopo" => FontSlot::EastAsian,
b"Arab" | b"Hebr" | b"Syrc" | b"Thaa" | b"Nkoo" | b"Deva" | b"Beng" | b"Guru" | b"Gujr" | b"Orya" | b"Taml" | b"Telu" | b"Knda" | b"Mlym"
| b"Sinh" | b"Thai" | b"Laoo" | b"Tibt" | b"Mymr" | b"Khmr" => FontSlot::ComplexScript,
_ => FontSlot::Latin,
}
}
/// The slot of `c` on its own: `None` for characters that take their neighbours' slot. CJK
/// punctuation and full-width forms are East Asian although their script is Common.
pub fn slot_of(c: char) -> Option<FontSlot> {
if let Some(s) = script_of(c) {
return Some(slot_of_script(s));
}
let cp = c as u32;
if (0x3000..=0x303F).contains(&cp) || (0xFF00..=0xFFEF).contains(&cp) || (0x3200..=0x33FF).contains(&cp) {
return Some(FontSlot::EastAsian);
}
None
}
/// A default BCP 47 language for a script (shaping picks `locl` forms by language).
pub fn default_language(s: ScriptTag) -> Option<&'static str> {
Some(match &s {
b"Arab" => "ar",
b"Hebr" => "he",
b"Syrc" => "syr",
b"Thaa" => "dv",
b"Thai" => "th",
b"Deva" => "hi",
_ => return None,
})
}
/// Resolve every character's script and slot, giving Common/Inherited characters the value of the
/// preceding character with one (or, at the start, the following one; Latin when there is none).
pub fn resolve(chars: &[char]) -> Vec<(Option<ScriptTag>, FontSlot)> {
let own: Vec<(Option<ScriptTag>, Option<FontSlot>)> = chars.iter().map(|c| (script_of(*c), slot_of(*c))).collect();
let first = own.iter().find_map(|(s, sl)| sl.map(|sl| (*s, sl))).unwrap_or((None, FontSlot::Latin));
let mut prev = first;
own.into_iter()
.map(|(s, sl)| {
if let Some(sl) = sl {
prev = (s, sl);
}
prev
})
.collect()
}
/// Arabic letters that join to the following letter (joining type D, dual-joining): a kashida
/// (tatweel) may be inserted after them. Right-joining (alef, dal, reh, waw…) and non-joining
/// (hamza) letters are excluded.
pub fn joins_following(c: char) -> bool {
let cp = c as u32;
let letter = matches!(cp, 0x0620..=0x064A | 0x066E..=0x06D3 | 0x06FA..=0x06FC | 0x06FF | 0x0750..=0x077F | 0x08A0..=0x08C9);
letter
&& !matches!(
cp,
0x0621..=0x0625 | 0x0627 | 0x0629 | 0x062F..=0x0632 | 0x0648 | 0x0671..=0x0673 | 0x0675..=0x0677 | 0x0688..=0x0699 | 0x06C0
| 0x06C3..=0x06CB | 0x06CD | 0x06CF | 0x06D2 | 0x06D3 | 0x0759..=0x075B | 0x076B | 0x076C | 0x0771 | 0x0773 | 0x0774
| 0x0778 | 0x0779 | 0x08AA..=0x08AC | 0x08AE | 0x08B1 | 0x08B2 | 0x08B9
)
}
/// An Arabic letter that joins to the preceding letter (joining type D or R).
pub fn joins_preceding(c: char) -> bool {
let cp = c as u32;
matches!(cp, 0x0622..=0x064A | 0x066E..=0x06D3 | 0x06FA..=0x06FC | 0x06FF | 0x0750..=0x077F | 0x08A0..=0x08C9) && cp != 0x0621
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scripts_and_slots() {
assert_eq!(script_of('س'), Some(ARABIC));
assert_eq!(script_of('ש'), Some(HEBREW));
assert_eq!(script_of('R'), Some(LATIN));
assert_eq!(script_of('2'), None);
assert_eq!(script_of('('), None);
// Harakat and tatweel are Inherited/Common.
assert_eq!(script_of('\u{064E}'), None);
assert_eq!(script_of('\u{0640}'), None);
assert_eq!(slot_of('س'), Some(FontSlot::ComplexScript));
assert_eq!(slot_of('日'), Some(FontSlot::EastAsian));
assert_eq!(slot_of('、'), Some(FontSlot::EastAsian));
assert_eq!(slot_of('a'), Some(FontSlot::Latin));
assert_eq!(slot_of(' '), None);
assert_eq!(script_of('\u{10FFFF}'), None);
assert_eq!(script_of('\0'), None);
}
#[test]
fn neutrals_take_the_surrounding_slot() {
let chars: Vec<char> = "سنة 2024 (Retrait) فقط".chars().collect();
let r = resolve(&chars);
// "سنة 2024 (" follows Arabic: complex script.
for (i, c) in chars.iter().enumerate().take(10) {
assert_eq!(r[i].1, FontSlot::ComplexScript, "{c:?}");
}
// "Retrait)" is Latin; the space after ")" stays Latin; "فقط" is complex script.
assert_eq!(r[10].1, FontSlot::Latin);
assert_eq!(r[17].1, FontSlot::Latin);
assert_eq!(r[19], (Some(ARABIC), FontSlot::ComplexScript));
// Leading neutrals take the first strong character's slot.
let r = resolve(&['(', 'س']);
assert_eq!(r[0].1, FontSlot::ComplexScript);
assert!(resolve(&[]).is_empty());
assert_eq!(resolve(&['1'])[0], (None, FontSlot::Latin));
}
#[test]
fn joining_classes() {
assert!(joins_following('س'));
assert!(joins_following('ن'));
assert!(!joins_following('ا'));
assert!(!joins_following('د'));
assert!(!joins_following('ء'));
assert!(joins_preceding('ا'));
assert!(!joins_preceding('ء'));
assert!(!joins_following('a'));
}
#[test]
fn ranges_are_sorted_and_disjoint() {
for w in RANGES.windows(2) {
assert!(w[0].1 < w[1].0, "{:x?} {:x?}", w[0], w[1]);
}
}
}
+124
View File
@@ -0,0 +1,124 @@
//! OpenType shaping with HarfRust.
//!
//! [`shape_range`] shapes one item of a paragraph (one face, one direction, one script) with the
//! rest of the paragraph as pre/post context, so Arabic letters at an item boundary (a colour
//! change inside a word, a bold letter) still take their joining forms. Glyphs come back in
//! visual order, exactly as the shaper produced them: right-to-left items are not reversed.
use std::ops::Range;
use harfrust::{Direction, Feature, Language, Script, ShapeOptions, Tag, UnicodeBuffer};
use skrifa::MetadataProvider;
use skrifa::instance::Size;
use crate::FontFace;
use crate::script::ScriptTag;
/// One shaped glyph, in font units.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShapedGlyph {
pub gid: u32,
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
pub cluster: usize,
pub x_advance: i32,
pub x_offset: i32,
pub y_offset: i32,
}
/// How to shape an item.
#[derive(Clone, Copy, Debug, Default)]
pub struct ShapeParams<'a> {
pub features: &'a [Feature],
/// Right-to-left (odd bidi level). Mirrored characters (brackets) take their mirrored glyphs.
pub rtl: bool,
/// ISO 15924 script (`*b"Arab"`); `None` lets the shaper guess from the text.
pub script: Option<ScriptTag>,
/// BCP 47 language (`"ar"`, `"ar-MA"`); `None` = the script's default.
pub language: Option<&'a str>,
}
/// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
let (Some(pre), Some(text), Some(post)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
return Vec::new();
};
let mut out = Vec::with_capacity(text.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
buf.set_pre_context(pre);
buf.set_post_context(post);
for (i, c) in text.char_indices() {
buf.add(map(c), (range.start + i) as u32);
}
buf.guess_segment_properties();
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
buf.set_script(s);
}
if let Some(l) = params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
{
buf.set_language(l);
}
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
});
}
});
if shaped.is_none()
&& let Some(f) = face.skrifa()
{
// No shaper for this face: one glyph per character from the cmap, in visual order.
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), face.location());
for (i, c) in text.char_indices() {
let g = cmap.map(map(c)).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
}
if params.rtl {
out.reverse();
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DEFAULT_FAMILY, FontDb};
#[test]
fn rtl_items_come_in_visual_order_and_mirror() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let text = "(ab)";
let p = ShapeParams { rtl: true, ..Default::default() };
let g = shape_range(&face, text, 0..text.len(), &p, |c| c);
// Visual order: last cluster first.
assert_eq!(g.iter().map(|g| g.cluster).collect::<Vec<_>>(), vec![3, 2, 1, 0]);
// L4: in a right-to-left run "(" is drawn with the ")" glyph and vice versa.
if face.covers('(') && face.covers(')') {
assert_eq!(g[3].gid, face.glyph_for(')'));
assert_eq!(g[0].gid, face.glyph_for('('));
}
}
#[test]
fn clusters_point_into_the_whole_paragraph() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let para = "Hello world";
let g = shape_range(&face, para, 6..11, &ShapeParams::default(), |c| c);
assert_eq!(g.first().map(|g| g.cluster), Some(6));
assert!(shape_range(&face, para, 6..99, &ShapeParams::default(), |c| c).is_empty());
// Not a char boundary.
assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty());
}
}
+47 -12
View File
@@ -150,7 +150,7 @@ pub struct HrdParameters {
}
fn parse_hrd(r: &mut BitReader) -> Result<HrdParameters> {
let cpb_cnt = r.read_ue()? + 1;
let cpb_cnt = r.read_ue_plus(1)?;
ensure!(cpb_cnt <= 32, "cpb_cnt_minus1 out of range");
let mut h = HrdParameters { cpb_cnt, bit_rate_scale: r.read_bits(4)? as u8, cpb_size_scale: r.read_bits(4)? as u8, ..Default::default() };
for _ in 0..cpb_cnt {
@@ -353,8 +353,8 @@ impl Sps {
if chroma_format_idc == 3 {
separate_colour_plane = r.read_flag()?;
}
bit_depth_luma = r.read_ue()? + 8;
bit_depth_chroma = r.read_ue()? + 8;
bit_depth_luma = r.read_ue_plus(8)?;
bit_depth_chroma = r.read_ue_plus(8)?;
ensure!(bit_depth_luma <= 14 && bit_depth_chroma <= 14, "bit depth out of range");
bypass = r.read_flag()?;
seq_scaling_matrix_present = r.read_flag()?;
@@ -363,7 +363,7 @@ impl Sps {
scaling_lists = parse_scaling_lists(&mut r, count, None)?;
}
}
let log2_max_frame_num = r.read_ue()? + 4;
let log2_max_frame_num = r.read_ue_plus(4)?;
ensure!(log2_max_frame_num <= 16, "log2_max_frame_num out of range");
let pic_order_cnt_type = r.read_ue()?;
ensure!(pic_order_cnt_type <= 2, "pic_order_cnt_type out of range");
@@ -373,7 +373,7 @@ impl Sps {
let mut offset_for_top_to_bottom_field = 0;
let mut offset_for_ref_frame = Vec::new();
if pic_order_cnt_type == 0 {
log2_max_poc_lsb = r.read_ue()? + 4;
log2_max_poc_lsb = r.read_ue_plus(4)?;
ensure!(log2_max_poc_lsb <= 16, "log2_max_pic_order_cnt_lsb out of range");
} else if pic_order_cnt_type == 1 {
delta_pic_order_always_zero = r.read_flag()?;
@@ -388,8 +388,8 @@ impl Sps {
let max_num_ref_frames = r.read_ue()?;
ensure!(max_num_ref_frames <= 16, "max_num_ref_frames out of range");
let gaps_in_frame_num_allowed = r.read_flag()?;
let pic_width_in_mbs = r.read_ue()? + 1;
let pic_height_in_map_units = r.read_ue()? + 1;
let pic_width_in_mbs = r.read_ue_plus(1)?;
let pic_height_in_map_units = r.read_ue_plus(1)?;
ensure!(pic_width_in_mbs <= 1024 && pic_height_in_map_units <= 1024, "picture too large");
let frame_mbs_only = r.read_flag()?;
let mb_adaptive_frame_field = if !frame_mbs_only { r.read_flag()? } else { false };
@@ -429,7 +429,8 @@ impl Sps {
};
if let Some((l, rr, t, b)) = sps.frame_crop {
let (cx, cy) = sps.crop_unit();
ensure!((l + rr) * cx < sps.width() && (t + b) * cy < sps.height(), "frame cropping exceeds picture size");
let crop = |a: u32, b: u32, unit: u32| (u64::from(a) + u64::from(b)) * u64::from(unit);
ensure!(crop(l, rr, cx) < u64::from(sps.width()) && crop(t, b, cy) < u64::from(sps.height()), "frame cropping exceeds picture size");
}
Ok(sps)
}
@@ -572,7 +573,7 @@ impl Pps {
.ok_or_else(|| crate::Error::MissingParameterSet(format!("SPS {sps_id} for PPS {id}")))?;
let entropy_coding_mode = r.read_flag()?;
let bottom_field_pic_order_in_frame_present = r.read_flag()?;
let num_slice_groups = r.read_ue()? + 1;
let num_slice_groups = r.read_ue_plus(1)?;
ensure!(num_slice_groups <= 8, "num_slice_groups out of range");
let mut slice_group_map_type = 0;
if num_slice_groups > 1 {
@@ -595,7 +596,7 @@ impl Pps {
r.read_ue()?;
}
6 => {
let n = r.read_ue()? + 1;
let n = r.read_ue_plus(1)?;
let bits = 32 - (num_slice_groups - 1).leading_zeros();
for _ in 0..n {
r.read_bits(bits)?;
@@ -604,8 +605,8 @@ impl Pps {
_ => {}
}
}
let num_ref_idx_l0_default_active = r.read_ue()? + 1;
let num_ref_idx_l1_default_active = r.read_ue()? + 1;
let num_ref_idx_l0_default_active = r.read_ue_plus(1)?;
let num_ref_idx_l1_default_active = r.read_ue_plus(1)?;
ensure!(num_ref_idx_l0_default_active <= 32 && num_ref_idx_l1_default_active <= 32, "num_ref_idx_default_active out of range");
let weighted_pred = r.read_flag()?;
let weighted_bipred_idc = r.read_bits(2)?;
@@ -716,4 +717,38 @@ mod tests {
assert_eq!(sps.pic_order_cnt_type, 2);
assert_eq!(sps.crop_rect(), (0, 0, 176, 144));
}
/// The baseline SPS of `parse_minimal_baseline_sps` with a chosen width and optional cropping.
fn sps_with(width_minus1: u32, crop: Option<[u32; 4]>) -> Vec<u8> {
let mut w = BitWriter::new();
w.write_bits(66, 8);
w.write_bits(0xC0, 8);
w.write_bits(30, 8);
w.write_ue(0);
w.write_ue(0);
w.write_ue(2);
w.write_ue(1);
w.write_bit(false);
w.write_ue(width_minus1);
w.write_ue(8);
w.write_bit(true); // frame_mbs_only
w.write_bit(true); // direct_8x8
w.write_bit(crop.is_some());
if let Some(c) = crop {
c.iter().for_each(|&v| w.write_ue(v));
}
w.write_bit(false); // vui
w.rbsp_trailing();
w.finish()
}
#[test]
fn hostile_sps_values_are_errors_not_overflows() {
// pic_width_in_mbs_minus1 = u32::MAX: `+ 1` used to overflow (wrapping to 0 in release).
assert!(Sps::parse(&sps_with(u32::MAX, None)).is_err());
// Crop offsets whose sum or product exceed u32.
assert!(Sps::parse(&sps_with(10, Some([u32::MAX - 1, u32::MAX - 1, 0, 0]))).is_err());
assert!(Sps::parse(&sps_with(10, Some([0x8000_0000, 0, 0, 0]))).is_err());
assert!(Sps::parse(&sps_with(10, Some([1, 1, 1, 1]))).is_ok());
}
}
+2 -2
View File
@@ -199,9 +199,9 @@ impl SliceHeader {
let mut num_ref_idx_active = [pps.num_ref_idx_l0_default_active, pps.num_ref_idx_l1_default_active];
if !slice_type.is_intra() {
if r.read_flag()? {
num_ref_idx_active[0] = r.read_ue()? + 1;
num_ref_idx_active[0] = r.read_ue_plus(1)?;
if slice_type.is_b() {
num_ref_idx_active[1] = r.read_ue()? + 1;
num_ref_idx_active[1] = r.read_ue_plus(1)?;
}
}
let max = if field_pic { 32 } else { 16 };
+92 -25
View File
@@ -97,7 +97,7 @@ pub fn skip_hrd(r: &mut BitReader, common: bool, max_sub_layers_minus1: u32) ->
}
let mut cpb_cnt = 1;
if !low_delay {
cpb_cnt = r.read_ue()? + 1;
cpb_cnt = r.read_ue_plus(1)?;
ensure!(cpb_cnt <= 32, "cpb_cnt_minus1 out of range");
}
if nal {
@@ -259,7 +259,7 @@ impl ScalingList {
while m < 6 {
let coef_num = if size == 0 { 16 } else { 64 };
if !r.read_flag()? {
let delta = r.read_ue()? as usize * step;
let delta = (r.read_ue()? as usize).saturating_mul(step);
ensure!(delta <= m, "scaling_list_pred_matrix_id_delta out of range");
if delta == 0 {
// default list
@@ -349,12 +349,13 @@ impl StRps {
pub fn parse(r: &mut BitReader, idx: usize, sets: &[StRps], num_sets: usize) -> Result<StRps> {
let inter = if idx != 0 { r.read_flag()? } else { false };
if inter {
let delta_idx = if idx == num_sets { r.read_ue()? as usize + 1 } else { 1 };
let delta_idx = if idx == num_sets { r.read_ue_plus(1)? as usize } else { 1 };
ensure!(delta_idx <= idx, "delta_idx_minus1 out of range");
let rf = &sets[idx - delta_idx];
let sign = r.read_flag()?;
let abs = r.read_ue()? as i32 + 1;
let abs = r.read_ue_plus(1)?;
ensure!(abs <= 1 << 15, "abs_delta_rps_minus1 out of range");
let abs = abs as i32;
let delta_rps = if sign { -abs } else { abs };
let n = rf.num_delta_pocs();
let mut used = vec![false; n + 1];
@@ -406,15 +407,17 @@ impl StRps {
let mut out = StRps::default();
let mut poc = 0i32;
for _ in 0..nneg {
let d = r.read_ue()? as i32 + 1;
let d = r.read_ue_plus(1)?;
ensure!(d <= 1 << 15, "delta_poc_s0_minus1 out of range");
let d = d as i32;
poc -= d;
out.s0.push((poc, r.read_flag()?));
}
poc = 0;
for _ in 0..npos {
let d = r.read_ue()? as i32 + 1;
let d = r.read_ue_plus(1)?;
ensure!(d <= 1 << 15, "delta_poc_s1_minus1 out of range");
let d = d as i32;
poc += d;
out.s1.push((poc, r.read_flag()?));
}
@@ -491,25 +494,25 @@ impl Sps {
if r.read_flag()? {
conf_win = (r.read_ue()?, r.read_ue()?, r.read_ue()?, r.read_ue()?);
}
let bit_depth_luma = r.read_ue()? + 8;
let bit_depth_chroma = r.read_ue()? + 8;
let bit_depth_luma = r.read_ue_plus(8)?;
let bit_depth_chroma = r.read_ue_plus(8)?;
ensure!(bit_depth_luma <= 16 && bit_depth_chroma <= 16, "bit depth out of range");
let log2_max_poc_lsb = r.read_ue()? + 4;
let log2_max_poc_lsb = r.read_ue_plus(4)?;
ensure!(log2_max_poc_lsb <= 16, "log2_max_pic_order_cnt_lsb_minus4 out of range");
let ordering_all = r.read_flag()?;
let (mut dpb, mut reorder, mut latency) = (1, 0, 0);
let start = if ordering_all { 0 } else { max_sub_layers_minus1 };
for _ in start..=max_sub_layers_minus1 {
// the values of the highest sub-layer are used
dpb = r.read_ue()? + 1;
dpb = r.read_ue_plus(1)?;
reorder = r.read_ue()?;
latency = r.read_ue()?;
}
ensure!(dpb <= 16 && reorder <= 16, "sps_max_dec_pic_buffering out of range");
let log2_min_cb = r.read_ue()? + 3;
let log2_ctb = log2_min_cb + r.read_ue()?;
let log2_min_tb = r.read_ue()? + 2;
let log2_max_tb = log2_min_tb + r.read_ue()?;
let log2_min_cb = r.read_ue_plus(3)?;
let log2_ctb = log2_min_cb.saturating_add(r.read_ue()?);
let log2_min_tb = r.read_ue_plus(2)?;
let log2_max_tb = log2_min_tb.saturating_add(r.read_ue()?);
ensure!((4..=6).contains(&log2_ctb) && log2_min_cb <= log2_ctb, "CTB size out of range");
ensure!(log2_min_tb < log2_min_cb && log2_max_tb <= log2_ctb.min(5), "transform block sizes out of range");
ensure!(width % (1 << log2_min_cb) == 0 && height % (1 << log2_min_cb) == 0, "picture size not a multiple of MinCbSizeY");
@@ -532,8 +535,8 @@ impl Sps {
if pcm {
pcm_bit_depth_luma = r.read_bits(4)? + 1;
pcm_bit_depth_chroma = r.read_bits(4)? + 1;
log2_min_pcm = r.read_ue()? + 3;
log2_max_pcm = log2_min_pcm + r.read_ue()?;
log2_min_pcm = r.read_ue_plus(3)?;
log2_max_pcm = log2_min_pcm.saturating_add(r.read_ue()?);
pcm_loop_filter_disabled = r.read_flag()?;
ensure!(pcm_bit_depth_luma <= bit_depth_luma && pcm_bit_depth_chroma <= bit_depth_chroma, "PCM bit depth out of range");
ensure!(log2_max_pcm <= log2_ctb.min(5), "PCM size out of range");
@@ -637,10 +640,10 @@ impl Sps {
pub fn crop_rect(&self) -> (u32, u32, u32, u32) {
let (l, r, t, b) = self.conf_win;
let (sw, sh) = (self.sub_width_c(), self.sub_height_c());
let x = (l * sw).min(self.width - 1);
let y = (t * sh).min(self.height - 1);
let w = self.width.saturating_sub(x + r * sw).max(1);
let h = self.height.saturating_sub(y + b * sh).max(1);
let x = l.saturating_mul(sw).min(self.width - 1);
let y = t.saturating_mul(sh).min(self.height - 1);
let w = self.width.saturating_sub(x.saturating_add(r.saturating_mul(sw))).max(1);
let h = self.height.saturating_sub(y.saturating_add(b.saturating_mul(sh))).max(1);
(x, y, w, h)
}
@@ -720,8 +723,8 @@ impl Pps {
let num_extra_slice_header_bits = r.read_bits(3)?;
let sign_data_hiding = r.read_flag()?;
let cabac_init_present = r.read_flag()?;
let num_ref_idx_l0_default = r.read_ue()? + 1;
let num_ref_idx_l1_default = r.read_ue()? + 1;
let num_ref_idx_l0_default = r.read_ue_plus(1)?;
let num_ref_idx_l1_default = r.read_ue_plus(1)?;
ensure!(num_ref_idx_l0_default <= 15 && num_ref_idx_l1_default <= 15, "num_ref_idx_default_active out of range");
let init_qp = 26 + r.read_se()?;
let constrained_intra_pred = r.read_flag()?;
@@ -742,8 +745,8 @@ impl Pps {
let (mut column_widths, mut row_heights) = (Vec::new(), Vec::new());
let mut loop_filter_across_tiles = true;
if tiles_enabled {
num_tile_columns = r.read_ue()? + 1;
num_tile_rows = r.read_ue()? + 1;
num_tile_columns = r.read_ue_plus(1)?;
num_tile_rows = r.read_ue_plus(1)?;
ensure!(num_tile_columns <= 64 && num_tile_rows <= 64, "too many tiles");
uniform_spacing = r.read_flag()?;
if !uniform_spacing {
@@ -770,7 +773,7 @@ impl Pps {
}
let scaling_list = if r.read_flag()? { Some(ScalingList::parse(&mut r)?) } else { None };
let lists_modification_present = r.read_flag()?;
let log2_parallel_merge_level = r.read_ue()? + 2;
let log2_parallel_merge_level = r.read_ue_plus(2)?;
ensure!(log2_parallel_merge_level <= 6, "log2_parallel_merge_level out of range");
let slice_header_extension_present = r.read_flag()?;
let mut range_extension = false;
@@ -933,3 +936,67 @@ impl Layout {
self.tile_id[self.rs_to_ts[rs as usize] as usize]
}
}
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_bitstream::BitWriter;
/// A 64x64 Main-profile SPS (CTB 64, no VUI) with a chosen conformance window and CTB-size delta.
fn sps_bits(conf_win: Option<[u32; 4]>, log2_diff_cb: u32) -> Vec<u8> {
let mut w = BitWriter::new();
w.write_bits(0, 4); // vps id
w.write_bits(0, 3); // max_sub_layers_minus1
w.write_bit(true); // temporal_id_nesting
w.write_bits(0, 2); // profile_space
w.write_bit(false); // tier
w.write_bits(1, 5); // profile_idc
w.write_bits(0x6000_0000, 32); // compatibility
w.write_bits(0b1001, 4); // progressive, interlaced, non_packed, frame_only
w.write_bits(0, 32);
w.write_bits(0, 12); // 43 constraint bits + 1 reserved
w.write_bits(93, 8); // level_idc
[0, 1, 64, 64].iter().for_each(|&v| w.write_ue(v)); // sps id, chroma 4:2:0, width, height
w.write_bit(conf_win.is_some());
conf_win.iter().flatten().for_each(|&v| w.write_ue(v));
[0, 0, 4].iter().for_each(|&v| w.write_ue(v)); // bit depths, log2_max_poc_lsb_minus4
w.write_bit(true); // sub_layer_ordering_info_present
[0, 0, 0].iter().for_each(|&v| w.write_ue(v)); // dpb, reorder, latency
w.write_ue(0); // log2_min_cb_minus3
w.write_ue(log2_diff_cb);
w.write_ue(0); // log2_min_tb_minus2
w.write_ue(3); // log2_diff_max_min_tb
w.write_ue(1); // max_transform_hierarchy_depth_inter
w.write_ue(1); // ... intra
[false; 4].iter().for_each(|&b| w.write_bit(b)); // scaling list, amp, sao, pcm
w.write_ue(0); // num_short_term_ref_pic_sets
[false; 5].iter().for_each(|&b| w.write_bit(b)); // long term, temporal mvp, strong intra, vui, extension
w.rbsp_trailing();
w.finish()
}
#[test]
fn hostile_sps_values_are_errors_not_overflows() {
assert!(Sps::parse(&sps_bits(None, 3)).is_ok());
// log2_diff_max_min_luma_coding_block_size = u32::MAX: `log2_min_cb + ue` used to overflow.
assert!(Sps::parse(&sps_bits(None, u32::MAX)).is_err());
// Conformance window offsets whose product with SubWidthC / SubHeightC exceeds u32.
let sps = Sps::parse(&sps_bits(Some([u32::MAX; 4]), 3)).unwrap();
let (x, y, w, h) = sps.crop_rect();
assert!(x < 64 && y < 64 && w >= 1 && h >= 1, "{:?}", (x, y, w, h));
}
#[test]
fn hostile_st_rps_deltas_are_errors_not_overflows() {
for ue in [0x7FFF_FFFF, 0x8000_0000, u32::MAX] {
let mut w = BitWriter::new();
w.write_ue(1); // num_negative_pics
w.write_ue(0); // num_positive_pics
w.write_ue(ue); // delta_poc_s0_minus1
w.write_bit(true);
w.rbsp_trailing();
let b = w.finish();
assert!(StRps::parse(&mut BitReader::new(&b), 0, &[], 1).is_err(), "ue {ue:#x}");
}
}
}
+3 -3
View File
@@ -293,9 +293,9 @@ impl SliceHeader {
if sh.slice_type != SliceType::I {
sh.num_ref_idx = [pps.num_ref_idx_l0_default, if sh.is_b() { pps.num_ref_idx_l1_default } else { 0 }];
if r.read_flag()? {
sh.num_ref_idx[0] = r.read_ue()? + 1;
sh.num_ref_idx[0] = r.read_ue_plus(1)?;
if sh.is_b() {
sh.num_ref_idx[1] = r.read_ue()? + 1;
sh.num_ref_idx[1] = r.read_ue_plus(1)?;
}
}
ensure!(sh.num_ref_idx[0] <= 15 && sh.num_ref_idx[1] <= 15, "num_ref_idx_active out of range");
@@ -365,7 +365,7 @@ impl SliceHeader {
let n = r.read_ue()?;
ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range");
if n > 0 {
let len = r.read_ue()? + 1;
let len = r.read_ue_plus(1)?;
ensure!(len <= 32, "offset_len_minus1 out of range");
for _ in 0..n {
sh.entry_points.push(r.read_bits(len)? + 1);
+5 -1
View File
@@ -132,6 +132,8 @@ pub enum Align {
Center,
Right,
Justify,
/// Justified with kashida (tatweel) elongation for Arabic (`justLow`).
JustLow,
Distributed,
}
@@ -142,6 +144,7 @@ impl Align {
Align::Center => "ctr",
Align::Right => "r",
Align::Justify => "just",
Align::JustLow => "justLow",
Align::Distributed => "dist",
}
}
@@ -150,7 +153,8 @@ impl Align {
"l" => Align::Left,
"ctr" => Align::Center,
"r" => Align::Right,
"just" | "justLow" | "thaiDist" => Align::Justify,
"just" | "thaiDist" => Align::Justify,
"justLow" => Align::JustLow,
"dist" => Align::Distributed,
_ => return None,
})
+18
View File
@@ -11,6 +11,24 @@ pub struct FontSet {
pub latin: String,
pub ea: String,
pub cs: String,
/// Per-script typefaces (`a:font script="Arab" typeface="…"`), in document order.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub scripts: Vec<ScriptFont>,
}
/// A theme font for one script (ISO 15924 tag, e.g. `Arab`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ScriptFont {
pub script: String,
pub typeface: String,
}
impl FontSet {
/// The typeface the theme gives `script` (`"Arab"`), if any.
pub fn script(&self, script: &str) -> Option<&str> {
self.scripts.iter().find(|f| f.script.eq_ignore_ascii_case(script) && !f.typeface.is_empty()).map(|f| f.typeface.as_str())
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+7 -1
View File
@@ -34,8 +34,14 @@ pub enum PptxError {
}
/// Does this look like an OOXML package (zip with `[Content_Types].xml`)?
///
/// The part is looked up in the zip's central directory, not in the leading bytes: writers may
/// store it anywhere in the archive (recent PowerPoint builds put it last, megabytes in).
pub fn sniff(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK") && bytes.windows(19).take(4096).any(|w| w == b"[Content_Types].xml")
if !bytes.starts_with(b"PK") {
return false;
}
zip::ZipArchive::new(std::io::Cursor::new(bytes)).map(|z| z.index_for_name("[Content_Types].xml").is_some()).unwrap_or(false)
}
/// Read a `.pptx` / `.potx` / `.ppsx` package.
+10 -2
View File
@@ -2,14 +2,22 @@
use deckcraft_color::{ColorScheme, Rgba, SchemeSlot};
use deckcraft_model::style::{ColorBase, Effects};
use deckcraft_model::theme::{FontScheme, FontSet, FormatScheme, Theme, default_format};
use deckcraft_model::theme::{FontScheme, FontSet, FormatScheme, ScriptFont, Theme, default_format};
use super::{Imp, Part, dml};
use crate::xml::El;
fn font_set(e: Option<&El>) -> FontSet {
let tf = |n: &str| e.and_then(|e| e.child(n)).and_then(|c| c.attr("typeface")).unwrap_or("").to_string();
FontSet { latin: tf("latin"), ea: tf("ea"), cs: tf("cs") }
let scripts = e
.map(|e| {
e.children_named("font")
.filter_map(|f| Some(ScriptFont { script: f.attr("script")?.to_string(), typeface: f.attr("typeface").unwrap_or("").to_string() }))
.take(256)
.collect()
})
.unwrap_or_default();
FontSet { latin: tf("latin"), ea: tf("ea"), cs: tf("cs"), scripts }
}
pub fn read_theme(imp: &mut Imp, part: &Part, root: &El) -> Theme {
+3
View File
@@ -13,6 +13,9 @@ fn font_set(w: &mut W, tag: &str, f: &FontSet) {
w.empty("a:latin", A::new().a("typeface", if f.latin.is_empty() { "Arial" } else { &f.latin }));
w.empty("a:ea", A::new().a("typeface", &f.ea));
w.empty("a:cs", A::new().a("typeface", &f.cs));
for sf in &f.scripts {
w.empty("a:font", A::new().a("script", &sf.script).a("typeface", &sf.typeface));
}
w.close(tag);
}
+25
View File
@@ -686,3 +686,28 @@ fn bisect_slide_variants() {
std::fs::write(format!("{dir}/v-only{k}.pptx"), one(s)).expect("write");
}
}
#[test]
fn rtl_paragraphs_script_fonts_and_just_low_round_trip() {
let mut p = rich_deck();
let fonts = &mut Arc::make_mut(&mut p.masters[0]).theme.fonts;
fonts.minor.cs = "Noto Naskh Arabic".into();
fonts.minor.scripts = vec![
deckcraft_model::theme::ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() },
deckcraft_model::theme::ScriptFont { script: "Hebr".into(), typeface: "Noto Sans Hebrew".into() },
];
let slide = Arc::make_mut(&mut p.slides[0]);
let sh = slide.shapes.iter_mut().find(|s| s.text.is_some()).expect("a text shape");
let body = sh.text.as_mut().expect("text");
body.paragraphs = vec![Paragraph { runs: vec![Run::new("سنة 2024 (Retrait) فقط")], ..Default::default() }];
body.paragraphs[0].props.rtl = Some(true);
body.paragraphs[0].props.align = Some(Align::JustLow);
let name = sh.name.clone();
let q = round(&p);
assert_eq!(q.masters[0].theme.fonts.minor.scripts, p.masters[0].theme.fonts.minor.scripts);
assert_eq!(q.masters[0].theme.fonts.minor.script("arab"), Some("Noto Naskh Arabic"));
let para = &by_name(&q.slides[0], &name).text.as_ref().expect("text").paragraphs[0];
assert_eq!(para.props.rtl, Some(true));
assert_eq!(para.props.align, Some(Align::JustLow));
assert_eq!(para.runs[0].text, "سنة 2024 (Retrait) فقط");
}
+58
View File
@@ -0,0 +1,58 @@
//! `sniff` must recognise a package wherever the writer put `[Content_Types].xml`: recent
//! PowerPoint builds store it as the last zip entry, well past the first few kilobytes.
use std::io::{Cursor, Read, Write};
use deckcraft_engine::Session;
use zip::write::SimpleFileOptions;
fn sample_pptx() -> Vec<u8> {
let mut s = Session::new();
deckcraft_engine::sample::open_sample(&mut s).expect("sample");
let p = s.doc().expect("doc").doc.as_ref().clone();
deckcraft_pptx::export(&p).expect("export")
}
/// Re-pack `bytes` with `[Content_Types].xml` moved to the end and a padding part in front, so
/// the content-types name first appears far beyond any fixed-size prefix.
fn content_types_last(bytes: &[u8]) -> Vec<u8> {
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
out.start_file("ppt/media/padding.bin", stored).expect("start");
out.write_all(&vec![0u8; 64 * 1024]).expect("padding");
let names: Vec<String> = (0..src.len()).map(|i| src.by_index(i).expect("entry").name().to_string()).collect();
for name in names.iter().filter(|n| *n != "[Content_Types].xml").chain(std::iter::once(&"[Content_Types].xml".to_string())) {
let mut data = Vec::new();
src.by_name(name).expect("by_name").read_to_end(&mut data).expect("read");
out.start_file(name, SimpleFileOptions::default()).expect("start");
out.write_all(&data).expect("write");
}
out.finish().expect("finish").into_inner()
}
#[test]
fn sniff_accepts_content_types_anywhere_in_the_archive() {
let bytes = sample_pptx();
assert!(deckcraft_pptx::sniff(&bytes));
let moved = content_types_last(&bytes);
let first_4k = &moved[..4096.min(moved.len())];
assert!(!first_4k.windows(19).any(|w| w == b"[Content_Types].xml"), "test setup: name must not be in the prefix");
assert!(deckcraft_pptx::sniff(&moved));
let p = deckcraft_pptx::import(&moved).expect("import");
assert!(!p.slides.is_empty());
}
#[test]
fn sniff_rejects_non_packages() {
assert!(!deckcraft_pptx::sniff(b""));
assert!(!deckcraft_pptx::sniff(b"PK"));
assert!(!deckcraft_pptx::sniff(b"%PDF-1.7 [Content_Types].xml"));
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
out.start_file("hello.txt", SimpleFileOptions::default()).expect("start");
out.write_all(b"not an office package").expect("write");
let plain_zip = out.finish().expect("finish").into_inner();
assert!(!deckcraft_pptx::sniff(&plain_zip));
}
+142 -49
View File
@@ -9,6 +9,7 @@
mod chart;
mod images;
mod morph_text;
mod paint;
mod placed;
mod table;
@@ -29,7 +30,9 @@ use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
use vello_cpu::{RenderContext, Resources, peniko};
pub use images::decode as decode_image;
pub use morph_text::TextMorph;
pub use placed::{Placed, PlacedLink, PlacedText, place_slide};
pub use table::row_heights as table_row_heights;
/// Largest pixmap side we render (vello_cpu uses u16 sizes).
pub const MAX_SIDE: u32 = 16_000;
@@ -241,6 +244,26 @@ pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Ima
}
}
/// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the
/// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by
/// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`:
/// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or
/// characters ([`TextMorph`]), on top of the later shape of the pair. `opts.state` applies to
/// `shapes` only.
pub fn render_blend(pres: &Presentation, old: usize, new: usize, mix: f64, shapes: &[(Shape, bool)], text: &[TextMorph], opts: &RenderOpts) -> Image {
let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else { return Image::default() };
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, opts))
})) {
Ok(img) => img,
Err(_) => {
log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide");
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
render_slide(pres, new, opts)
}
}
}
/// Render a layout (Slide Master view thumbnail and canvas).
pub fn render_layout(pres: &Presentation, master: usize, layout: Option<usize>, opts: &RenderOpts) -> Image {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) {
@@ -371,46 +394,56 @@ struct Frame<'a> {
}
impl Renderer {
fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
fn canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) {
let (w, h) = output_size(pres, opts);
let mut ctx = RenderContext::new_with(
w,
h,
vello_cpu::RenderSettings {
num_threads: if cfg!(target_arch = "wasm32") || needs_filters(pres, slide) { 0 } else { opts.threads },
..Default::default()
},
vello_cpu::RenderSettings { num_threads: if cfg!(target_arch = "wasm32") || filters { 0 } else { opts.threads }, ..Default::default() },
);
if let Some(c) = opts.clear {
ctx.set_paint(color(c, 1.0));
ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64));
}
let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
let fields = SlideFields {
(ctx, w, h, view)
}
fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
SlideFields {
num: pres.slide_number(index),
date: opts.date_text.clone().unwrap_or_else(|| pres.header_footer.date_text.clone()),
footer: pres.header_footer.footer_text.clone(),
};
let f = Frame { pres, opts, fields };
}
}
/// Background plus the master and layout graphics the slide shows.
fn backdrop(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: &Slide, view: Affine) {
self.background(ctx, f, rctx, Some(slide), view);
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master {
let mctx = Ctx::for_master(f.pres, rctx.master);
for s in &rctx.master.shapes {
if s.ph.is_none() {
self.shape(ctx, f, &mctx, s, view, 0);
}
}
}
if show_layout && let Some(l) = rctx.layout {
let lctx = Ctx::for_layout(f.pres, rctx.master, l);
for s in &l.shapes {
if s.ph.is_none() {
self.shape(ctx, f, &lctx, s, view, 0);
}
}
}
}
fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
if let Some(rctx) = Ctx::for_slide(pres, slide) {
self.background(&mut ctx, &f, &rctx, Some(slide), view);
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master {
let mctx = Ctx::for_master(pres, rctx.master);
for s in &rctx.master.shapes {
if s.ph.is_none() {
self.shape(&mut ctx, &f, &mctx, s, view, 0);
}
}
}
if show_layout && let Some(l) = rctx.layout {
let lctx = Ctx::for_layout(pres, rctx.master, l);
for s in &l.shapes {
if s.ph.is_none() {
self.shape(&mut ctx, &f, &lctx, s, view, 0);
}
}
}
self.backdrop(&mut ctx, &f, &rctx, slide, view);
for s in &slide.shapes {
self.shape(&mut ctx, &f, &rctx, s, view, 0);
}
@@ -418,6 +451,60 @@ impl Renderer {
self.finish(ctx, w, h)
}
#[allow(clippy::too_many_arguments)]
fn blend(
&mut self,
pres: &Presentation,
old: (&Slide, usize),
new: (&Slide, usize),
mix: f64,
shapes: &[(Shape, bool)],
text: &[TextMorph],
opts: &RenderOpts,
) -> Image {
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0));
let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) };
// Backdrop graphics never take shape states: their ids may clash with the frame's shapes.
let bopts = RenderOpts { state: None, date_text: opts.date_text.clone(), ..*opts };
if mix < 1.0 {
let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, old.1, opts) };
self.backdrop(&mut ctx, &f, &co, old.0, view);
}
if mix > 0.0 {
let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, new.1, opts) };
let layered = mix < 1.0;
if layered {
ctx.push_layer(None, None, Some(mix as f32), None, None);
}
self.backdrop(&mut ctx, &f, &cn, new.0, view);
if layered {
ctx.pop_layer();
}
}
let fo = Frame { pres, opts, fields: Self::fields(pres, old.1, opts) };
let fnew = Frame { pres, opts, fields: Self::fields(pres, new.1, opts) };
let mut drawn: Vec<ShapeId> = Vec::new();
for (s, from_old) in shapes {
let (f, c) = if *from_old { (&fo, &co) } else { (&fnew, &cn) };
let Some(tm) = text.iter().find(|m| m.old == s.id || m.new == s.id) else {
self.shape(&mut ctx, f, c, s, view, 0);
continue;
};
self.shape(&mut ctx, f, c, &Shape { text: None, ..s.clone() }, view, 0);
drawn.push(s.id);
if !(drawn.contains(&tm.old) && drawn.contains(&tm.new)) {
continue;
}
let find = |id: ShapeId| shapes.iter().map(|(s, _)| s).find(|s| s.id == id);
if let (Some(a), Some(b)) = (find(tm.old), find(tm.new)) {
let now = b.xfrm.unwrap_or_else(|| resolve::xfrm(&cn, b));
morph_text::draw(&mut ctx, (&co, a, &fo.fields), (&cn, b, &fnew.fields), tm, now, view);
}
}
self.finish(ctx, w, h)
}
fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image {
let (w, h) = output_size(pres, opts);
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
@@ -971,30 +1058,8 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
continue;
}
let m = m * Affine::translate(ps.offset);
let alpha = run.alpha * ps.opacity;
let k = run.size / run.face.upem.max(1.0);
ctx.set_paint(color(run.color, alpha));
for (gid, x, y) in &run.glyphs {
let path = glyph(&run.face, *gid);
if path.elements().is_empty() {
continue;
}
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let gm = m * Affine::translate((*x, *y)) * skew * Affine::scale(k);
ctx.set_transform(gm);
if alpha > 0.0 && run.color.a > 0 {
ctx.fill_path(&path);
if run.fake_bold {
ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
if let Some((oc, ow)) = run.outline {
ctx.set_paint(color(oc, ps.opacity));
ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
ctx.set_paint(color(run.color, alpha));
}
draw_glyph(ctx, run, *gid, m * Affine::translate((*x, *y)), ps.opacity);
}
}
for d in l.decos.iter().filter(|d| !d.behind) {
@@ -1002,6 +1067,34 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
}
}
/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`.
pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) {
let alpha = run.alpha * opacity;
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
return;
}
let path = glyph(&run.face, gid);
if path.elements().is_empty() {
return;
}
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
ctx.set_transform(at * skew * Affine::scale(k));
if alpha > 0.0 && run.color.a > 0 {
ctx.set_paint(color(run.color, alpha));
ctx.fill_path(&path);
if run.fake_bold {
ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
if let Some((oc, ow)) = run.outline {
ctx.set_paint(color(oc, opacity));
ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
/// Render one shape alone onto a transparent canvas (drag previews, copy as picture).
pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option<Image> {
let rctx = Ctx::for_slide(pres, slide)?;
+227
View File
@@ -0,0 +1,227 @@
//! Morph by words or characters: the words (or characters) two texts share travel from their place
//! in the old text to their place in the new one, growing or shrinking to the new size; the rest
//! of the old text fades out and the rest of the new one fades in, both riding the moving box.
use deckcraft_geom::Xfrm;
use deckcraft_model::resolve::Ctx;
use deckcraft_model::{Shape, ShapeId};
use deckcraft_text::{Fields, GlyphRun, Opts, TextLayout};
use kurbo::{Affine, Point, Vec2};
use vello_cpu::RenderContext;
use crate::{color, draw_glyph, draw_layout, shape_geometry};
/// The texts of two frame shapes (see [`crate::render_blend`]) morphed by words or characters.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextMorph {
/// The frame shape from the old slide and the one from the new slide.
pub old: ShapeId,
pub new: ShapeId,
/// Their boxes on their own slides: where the shared words start and end.
pub from: Xfrm,
pub to: Xfrm,
/// Characters instead of words.
pub chars: bool,
/// Progress, 0..1 (already eased).
pub t: f64,
}
/// A word or character: its text, its glyphs (run, glyph) and the origin of its first glyph.
struct Token {
key: String,
glyphs: Vec<(usize, usize)>,
origin: Point,
run: usize,
}
/// Words (or characters) of a layout in reading order, plus glyphs that belong to none (bullets).
fn tokens(l: &TextLayout, chars: bool) -> (Vec<Token>, Vec<(usize, usize)>) {
let mut cells: Vec<(usize, usize, char, usize, usize)> = Vec::new();
let mut loose = Vec::new();
for (ri, run) in l.runs.iter().enumerate() {
for gi in 0..run.glyphs.len() {
match run.cells.get(gi) {
Some((ci, ch)) => cells.push((run.para, *ci, *ch, ri, gi)),
None => loose.push((ri, gi)),
}
}
}
cells.sort_by_key(|c| (c.0, c.1, c.3, c.4));
let mut out: Vec<Token> = Vec::new();
let mut last: Option<(usize, usize)> = None;
for (para, ci, ch, ri, gi) in cells {
if ch.is_whitespace() {
last = None;
continue;
}
let same_char = last == Some((para, ci));
let next_char = last.is_some_and(|(p, c)| p == para && c.checked_add(1) == Some(ci));
let extend = same_char || (!chars && next_char);
match out.last_mut() {
Some(tok) if extend => {
if !same_char {
tok.key.push(ch);
}
tok.glyphs.push((ri, gi));
}
_ => {
let origin = l.runs.get(ri).and_then(|r| r.glyphs.get(gi)).map(|g| Point::new(g.1, g.2)).unwrap_or(Point::ZERO);
out.push(Token { key: ch.to_string(), glyphs: vec![(ri, gi)], origin, run: ri });
}
}
last = Some((para, ci));
}
(out, loose)
}
/// For each token of `b`, the token of `a` with the same text, in reading order, each used once.
fn matches(a: &[Token], b: &[Token]) -> Vec<Option<usize>> {
let mut used = vec![false; a.len()];
b.iter()
.map(|tb| {
let i = a.iter().enumerate().position(|(i, ta)| !used.get(i).copied().unwrap_or(true) && ta.key == tb.key)?;
if let Some(u) = used.get_mut(i) {
*u = true;
}
Some(i)
})
.collect()
}
fn layout(ctx: &Ctx, s: &Shape, x: Xfrm, fields: &dyn Fields) -> Option<TextLayout> {
let body = s.text.as_ref().filter(|b| !b.is_empty())?;
let rect = shape_geometry(s, x.w, x.h).text_rect;
Some(deckcraft_text::layout(ctx, s, body, &Opts { rect, fields, prompt_color: None, no_shrink: false }))
}
fn glyph_at(l: &TextLayout, (ri, gi): (usize, usize)) -> Option<(&GlyphRun, u32, Point)> {
let run = l.runs.get(ri)?;
let g = run.glyphs.get(gi)?;
Some((run, g.0, Point::new(g.1, g.2)))
}
fn same_look(a: &GlyphRun, b: &GlyphRun) -> bool {
a.face.id() == b.face.id() && a.size == b.size && a.color == b.color && a.alpha == b.alpha && a.outline == b.outline
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
/// Draw the morphing text of `a` (old slide, `ca`) and `b` (new slide, `cb`); `now` is the box both
/// frame shapes share at this moment, `view` the slide-to-pixels transform.
pub(crate) fn draw(
ctx: &mut RenderContext,
(ca, a, fa): (&Ctx, &Shape, &dyn Fields),
(cb, b, fb): (&Ctx, &Shape, &dyn Fields),
m: &TextMorph,
now: Xfrm,
view: Affine,
) {
let t = if m.t.is_finite() { m.t.clamp(0.0, 1.0) } else { 1.0 };
let la = layout(ca, a, m.from, fa);
let lb = layout(cb, b, m.to, fb);
let mt = view * now.affine();
// Rotated (vertical) text: no per-word travel, the two texts cross-fade on the moving box.
let rotated = la.as_ref().is_some_and(|l| l.rotation != 0.0) || lb.as_ref().is_some_and(|l| l.rotation != 0.0);
if rotated || la.is_none() || lb.is_none() {
for (l, s, alpha, x) in [(la, a, 1.0 - t, m.from), (lb, b, t, m.to)] {
if let Some(l) = l
&& alpha > 0.001
{
ctx.push_layer(None, None, Some(alpha as f32), None, None);
draw_layout(ctx, &l, shape_geometry(s, x.w, x.h).text_rect, mt);
ctx.pop_layer();
}
}
return;
}
let (Some(la), Some(lb)) = (la, lb) else { return };
let (ta, loose_a) = tokens(&la, m.chars);
let (tb, loose_b) = tokens(&lb, m.chars);
let pairs = matches(&ta, &tb);
let (ma, mb) = (view * m.from.affine(), view * m.to.affine());
let c = mt.as_coeffs();
let linear = Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]);
let mut moved_a = vec![false; ta.len()];
// Shared tokens travel.
for (j, i) in pairs.iter().enumerate() {
let (Some(i), Some(tok_b)) = (*i, tb.get(j)) else { continue };
let Some(tok_a) = ta.get(i) else { continue };
if let Some(f) = moved_a.get_mut(i) {
*f = true;
}
let (Some(ra), Some(rb)) = (la.runs.get(tok_a.run), lb.runs.get(tok_b.run)) else { continue };
let p = (ma * tok_a.origin).lerp(mb * tok_b.origin, t);
let ratio = if ra.size > 0.0 && rb.size > 0.0 { rb.size / ra.size } else { 1.0 };
let same = same_look(ra, rb);
let alpha_a = if same { 1.0 } else { 1.0 - t };
let sa = lerp(1.0, ratio, t);
for &g in &tok_a.glyphs {
if let Some((run, gid, pos)) = glyph_at(&la, g) {
let at = Affine::translate(p.to_vec2()) * linear * Affine::scale(sa) * Affine::translate(pos - tok_a.origin);
draw_glyph(ctx, run, gid, at, alpha_a);
}
}
if !same {
let sb = lerp(1.0 / ratio, 1.0, t);
for &g in &tok_b.glyphs {
if let Some((run, gid, pos)) = glyph_at(&lb, g) {
let at = Affine::translate(p.to_vec2()) * linear * Affine::scale(sb) * Affine::translate(pos - tok_b.origin);
draw_glyph(ctx, run, gid, at, t);
}
}
}
}
// The rest fades while following the shared tokens: they define a scale and shift from the old
// text to the new one (F(p) = s·p + c, in pixels), so a line grows, shrinks and rearranges as
// one. With nothing shared it rides the moving box.
let shared: Vec<(Point, Point, f64)> = pairs
.iter()
.enumerate()
.filter_map(|(j, i)| {
let (tok_a, tok_b) = (ta.get((*i)?)?, tb.get(j)?);
let (ra, rb) = (la.runs.get(tok_a.run)?, lb.runs.get(tok_b.run)?);
let ratio = if ra.size > 0.0 && rb.size > 0.0 { rb.size / ra.size } else { 1.0 };
Some((ma * tok_a.origin, mb * tok_b.origin, ratio))
})
.collect();
let n = shared.len() as f64;
let s = shared.iter().map(|x| x.2).sum::<f64>() / n;
let c = shared.iter().fold(Vec2::ZERO, |acc, (pa, pb, _)| acc + (pb.to_vec2() - pa.to_vec2() * s)) / n;
let (place_a, place_b) = if !shared.is_empty() && s.is_finite() && s > 0.0 && c.is_finite() {
// Old text: p ↦ lerp(p, F(p), t). New text: q ↦ lerp(F⁻¹(q), q, t).
let a = Affine::translate(c * t) * Affine::scale(lerp(1.0, s, t));
let b = Affine::translate(-c * ((1.0 - t) / s)) * Affine::scale((1.0 - t) / s + t);
(a * ma, b * mb)
} else {
(mt, mt)
};
let unmatched_a =
ta.iter().enumerate().filter(|(i, _)| !moved_a.get(*i).copied().unwrap_or(false)).flat_map(|(_, tok)| tok.glyphs.iter().copied());
for g in unmatched_a.chain(loose_a) {
if let Some((run, gid, pos)) = glyph_at(&la, g) {
draw_glyph(ctx, run, gid, place_a * Affine::translate(pos.to_vec2()), 1.0 - t);
}
}
let unmatched_b =
tb.iter().enumerate().filter(|(j, _)| pairs.get(*j).is_none_or(|p| p.is_none())).flat_map(|(_, tok)| tok.glyphs.iter().copied());
for g in unmatched_b.chain(loose_b) {
if let Some((run, gid, pos)) = glyph_at(&lb, g) {
draw_glyph(ctx, run, gid, place_b * Affine::translate(pos.to_vec2()), t);
}
}
// Underlines, strikethroughs and highlights fade with their text.
for (l, alpha, place) in [(&la, 1.0 - t, place_a), (&lb, t, place_b)] {
for d in &l.decos {
ctx.set_transform(place);
ctx.set_paint(color(d.color, alpha));
ctx.fill_rect(&d.rect);
}
}
}
#[cfg(test)]
pub(crate) fn token_keys(l: &TextLayout, chars: bool) -> Vec<String> {
tokens(l, chars).0.into_iter().map(|t| t.key).collect()
}
+34 -20
View File
@@ -7,7 +7,7 @@ use deckcraft_model::{Presentation, Shape, ShapeKind};
use deckcraft_text::{Opts, TextLayout};
use kurbo::{Affine, Rect};
use crate::{SlideFields, shape_geometry};
use crate::{SlideFields, shape_geometry, table};
/// One shape's text as drawn on the slide.
pub struct PlacedText {
@@ -76,32 +76,46 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
place(out, rctx, fields, c, gm, depth + 1);
}
}
ShapeKind::Table(_) | ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {}
ShapeKind::Table(t) => {
// Cell text, laid out exactly as the table draws it.
for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) {
let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue };
let body = table::cell_body(rctx, t, r, c, cell);
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
place_text(out, rctx, fields, &tmp, &body, rect, m);
}
}
ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {}
_ => {
let Some(body) = s.text.as_ref().filter(|b| !b.is_empty()) else { return };
let tr = shape_geometry(s, x.w, x.h).text_rect;
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
let m = if layout.rotation != 0.0 {
let c = tr.center().to_vec2();
m * Affine::translate(c) * Affine::rotate(layout.rotation.to_radians()) * Affine::translate(-c)
} else {
m
};
// Text hyperlinks: one box per line segment of a linked run.
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
let x0 = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
let last = run.glyphs.last().map(|g| g.1).unwrap_or(x0);
let x1 = line.caret_x.get(run.chars.1.saturating_sub(line.start)).copied().unwrap_or(last).max(last);
let r = Rect::new(x0, line.top, x1, line.bottom);
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link });
}
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
place_text(out, rctx, fields, s, body, tr, m);
}
}
}
/// Lay out `body` in text rect `tr` of shape `s` (shape-local → slide `m`) and record it and its hyperlinks.
fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, body: &TextBody, tr: Rect, m: Affine) {
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
let m = if layout.rotation != 0.0 {
let c = tr.center().to_vec2();
m * Affine::translate(c) * Affine::rotate(layout.rotation.to_radians()) * Affine::translate(-c)
} else {
m
};
// Text hyperlinks: one box per line segment of a linked run.
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
// The run's visual extent (right-to-left runs run from right to left).
let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
let (x0, x1) = line.span(run.chars.0, run.chars.1).unwrap_or((first, first));
let r = Rect::new(x0, line.top, x1, line.bottom);
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link });
}
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
}
/// The hyperlink of the model run containing character `at` of paragraph `para`.
fn run_link(body: &TextBody, para: usize, at: usize) -> Option<Hyperlink> {
let p = body.paragraphs.get(para)?;
+73 -39
View File
@@ -3,9 +3,10 @@
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::resolve::Ctx;
use deckcraft_model::style::{ColorRef, Fill};
use deckcraft_model::table::Table;
use deckcraft_model::text::Anchor;
use deckcraft_model::table::{Cell, Table};
use deckcraft_model::text::{Anchor, TextBody, TextDir};
use deckcraft_model::{Presentation, Shape};
use deckcraft_text::Fields;
use kurbo::{Affine, BezPath, Rect, Shape as _};
use vello_cpu::{RenderContext, peniko};
@@ -94,21 +95,76 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
}
}
/// Running edges 0, s0, s0+s1, … of a list of sizes (negative sizes count as 0).
fn edges(sizes: &[f64]) -> Vec<f64> {
std::iter::once(0.0)
.chain(sizes.iter().scan(0.0, |a, s| {
*a += s.max(0.0);
Some(*a)
}))
.collect()
}
/// A cell's text as drawn: its margins, anchor and direction, plus the table style's text colour and bold.
pub(crate) fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
let lk = look(rctx, t, r, c);
let mut body = cell.text.clone();
let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]);
body.body.inset_l = Some(ml);
body.body.inset_r = Some(mr);
body.body.inset_t = Some(mt);
body.body.inset_b = Some(mb);
body.body.anchor = Some(cell.anchor.unwrap_or(Anchor::Top));
if cell.vertical.is_some() {
body.body.vert = cell.vertical;
}
for p in &mut body.paragraphs {
for run in &mut p.runs {
if run.props.fill.is_none()
&& let Some(tc) = lk.text
{
run.props.fill = Some(Fill::solid(ColorRef::rgb(tc)));
}
if lk.bold && run.props.bold.is_none() {
run.props.bold = Some(true);
}
}
}
body
}
/// Heights the rows are drawn at. A stored row height (`a:tr/@h`) is a minimum: the row grows so the
/// text of each of its cells fits, as in PowerPoint. Files from pandoc or python-pptx store 0.
/// Cells spanning several rows and vertical text keep the stored height.
pub fn row_heights(rctx: &Ctx, t: &Table, fields: &dyn Fields) -> Vec<f64> {
let xs = edges(&t.cols);
let mut out = Vec::with_capacity(t.rows.len());
for (ri, row) in t.rows.iter().enumerate() {
let mut h = row.height.max(0.0);
for (ci, cell) in row.cells.iter().enumerate().take(t.cols.len()) {
if cell.is_covered() || cell.row_span > 1 || !matches!(cell.vertical, None | Some(TextDir::Horizontal)) {
continue;
}
let c1 = ci.saturating_add(cell.grid_span.max(1) as usize).min(t.cols.len());
let (Some(x0), Some(x1)) = (xs.get(ci), xs.get(c1)) else { continue };
let body = cell_body(rctx, t, ri, ci, cell);
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
let need = deckcraft_text::fit_height(rctx, &tmp, &body, Rect::new(*x0, 0.0, *x1, h.max(1.0)), fields);
if need.is_finite() {
h = h.max(need.min(20_000.0));
}
}
out.push(h);
}
out
}
/// Cell rectangles in table-local space, honouring merges: (row, col, rect) for visible cells.
pub fn cell_rects(t: &Table) -> Vec<(usize, usize, Rect)> {
/// `heights` are the drawn row heights ([`row_heights`]).
pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
let mut out = vec![];
let xs: Vec<f64> = std::iter::once(0.0)
.chain(t.cols.iter().scan(0.0, |a, w| {
*a += w.max(0.0);
Some(*a)
}))
.collect();
let ys: Vec<f64> = std::iter::once(0.0)
.chain(t.rows.iter().scan(0.0, |a, r| {
*a += r.height.max(0.0);
Some(*a)
}))
.collect();
let xs = edges(&t.cols);
let ys = edges(heights);
for (ri, row) in t.rows.iter().enumerate() {
for (ci, cell) in row.cells.iter().enumerate().take(t.cols.len()) {
if cell.is_covered() {
@@ -125,7 +181,7 @@ pub fn cell_rects(t: &Table) -> Vec<(usize, usize, Rect)> {
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
let _ = pres;
let rects = cell_rects(t);
let rects = cell_rects(t, &row_heights(rctx, t, fields));
for (r, c, rect) in &rects {
let Some(cell) = t.cell(*r, *c) else { continue };
let lk = look(rctx, t, *r, *c);
@@ -180,29 +236,7 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
if cell.text.is_empty() {
continue;
}
let lk = look(rctx, t, *r, *c);
let mut body = cell.text.clone();
let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]);
body.body.inset_l = Some(ml);
body.body.inset_r = Some(mr);
body.body.inset_t = Some(mt);
body.body.inset_b = Some(mb);
body.body.anchor = Some(cell.anchor.unwrap_or(Anchor::Top));
if cell.vertical.is_some() {
body.body.vert = cell.vertical;
}
for p in &mut body.paragraphs {
for run in &mut p.runs {
if run.props.fill.is_none()
&& let Some(tc) = lk.text
{
run.props.fill = Some(Fill::solid(ColorRef::rgb(tc)));
}
if lk.bold && run.props.bold.is_none() {
run.props.bold = Some(true);
}
}
}
let body = cell_body(rctx, t, *r, *c, cell);
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
let _ = peniko::Fill::NonZero;
+115
View File
@@ -157,3 +157,118 @@ fn custom_path_parse() {
assert!(p.elements().len() >= 5);
assert!(parse_path("").elements().is_empty());
}
#[test]
fn table_rows_of_zero_height_grow_to_fit_text() {
// pandoc and python-pptx write `<a:tr h="0">`: the stored row height is only a minimum.
let mut t = Table::new(3, 2, 300.0, 0.0);
for (r, row) in [["Col A", "Col B"], ["1", "alpha"], ["2", "beta"]].iter().enumerate() {
for (c, text) in row.iter().enumerate() {
t.cell_mut(r, c).unwrap().text = TextBody::from_text(text);
}
}
let tbl = Shape { id: ShapeId(610), xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 0.0)), kind: ShapeKind::Table(t.clone()), ..Default::default() };
let p = deck_with(vec![tbl]);
let ctx = deckcraft_model::resolve::Ctx::for_slide(&p, &p.slides[0]).unwrap();
let hs = crate::table::row_heights(&ctx, &t, &deckcraft_text::NoFields);
assert_eq!(hs.len(), 3);
assert!(hs.iter().all(|h| *h > 10.0), "{hs:?}");
let tops: Vec<f64> = crate::table::cell_rects(&t, &hs).iter().filter(|(_, c, _)| *c == 0).map(|(_, _, r)| r.y0).collect();
assert!(tops.len() == 3 && tops.windows(2).all(|w| w[1] > w[0] + 10.0), "{tops:?}");
// A taller stored height is kept.
let mut tall = t;
tall.rows[1].height = 200.0;
assert_eq!(crate::table::row_heights(&ctx, &tall, &deckcraft_text::NoFields)[1], 200.0);
// The PDF text layer gets every cell's text, rows one below the other.
let placed = place_slide(&p, 0);
let top = |s: &str| {
let pt = placed.texts.iter().find(|pt| pt.body.text() == s).unwrap_or_else(|| panic!("no placed text {s:?}"));
(pt.transform * kurbo::Point::new(0.0, pt.layout.lines[0].top)).y
};
assert!(top("Col A") < top("1") && top("1") < top("2"));
assert!((top("alpha") - top("1")).abs() < 1.0 && (top("beta") - top("2")).abs() < 1.0);
assert!(top("Col B") >= 100.0);
}
#[test]
fn blend_ends_match_the_slides_and_mixes_backdrops() {
let sh = Shape {
id: ShapeId(500),
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),
..Default::default()
};
let mut p = deck_with(vec![sh.clone()]);
let mut dark = (*p.slides[0]).clone();
dark.background =
Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))) });
p.slides.push(std::sync::Arc::new(dark));
let o = RenderOpts { scale: 0.5, ..Default::default() };
let shapes = vec![(sh, false)];
// At the ends the frame is the slide itself; the shape stays opaque throughout.
assert_eq!(render_blend(&p, 0, 1, 1.0, &shapes, &[], &o).pixels, render_slide(&p, 1, &o).pixels);
let mid = render_blend(&p, 0, 1, 0.5, &shapes, &[], &o);
let px = mid.pixel(5, 5);
assert!((px[0] as i32 - 128).abs() <= 3, "{px:?}");
assert_eq!(mid.pixel(100, 75), render_slide(&p, 1, &o).pixel(100, 75));
// Hostile input: bad indices and NaN never panic.
assert_eq!(render_blend(&p, 0, 9, 0.5, &shapes, &[], &o).width, 0);
let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &o);
}
fn text_shape(id: u32, text: &str, x: f64, y: f64) -> Shape {
let mut t = TextBody::from_text(text);
for p in &mut t.paragraphs {
for r in &mut p.runs {
r.props.size = Some(40.0);
}
}
Shape { id: ShapeId(id), xfrm: Some(Xfrm::new(x, y, 600.0, 120.0)), text_box: true, text: Some(t), ..Default::default() }
}
#[test]
fn morph_text_tokens_are_words_or_characters() {
let p = Presentation::default();
let rctx = Ctx::for_slide(&p, &p.slides[0]).unwrap();
let s = text_shape(1, "Hello big world\nHello", 0.0, 0.0);
let l = deckcraft_text::layout(
&rctx,
&s,
s.text.as_ref().unwrap(),
&Opts { rect: Rect::new(0.0, 0.0, 600.0, 120.0), fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
);
assert_eq!(morph_text::token_keys(&l, false), ["Hello", "big", "world", "Hello"]);
assert_eq!(morph_text::token_keys(&l, true).concat(), "Hellobigworld".to_string() + "Hello");
assert_eq!(morph_text::token_keys(&l, true).len(), 18);
}
#[test]
fn morph_text_ends_match_the_slides() {
let from = Xfrm::new(100.0, 60.0, 600.0, 120.0);
let to = Xfrm::new(200.0, 300.0, 600.0, 120.0);
let mut p = deck_with(vec![text_shape(1, "Hello wide world", from.x, from.y)]);
let mut b = (*p.slides[0]).clone();
b.shapes = vec![text_shape(1, "world says Hello", to.x, to.y)];
p.slides.push(std::sync::Arc::new(b));
let o = RenderOpts { scale: 0.5, ..Default::default() };
let frame = |t: f64| {
let x = Xfrm::new(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, 600.0, 120.0);
let mut sb = text_shape(10, "world says Hello", 0.0, 0.0);
let mut sa = text_shape(11, "Hello wide world", 0.0, 0.0);
sb.xfrm = Some(x);
sa.xfrm = Some(x);
let tm = TextMorph { old: ShapeId(11), new: ShapeId(10), from, to, chars: false, t };
render_blend(&p, 0, 1, t, &[(sb, false), (sa, true)], &[tm], &o)
};
// Warm up: faces load lazily (a background scan runs), so the first render may still fall back.
let _ = (render_slide(&p, 0, &o), render_slide(&p, 1, &o), frame(0.5));
let close = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 8).count();
let d0 = close(&frame(0.0), &render_slide(&p, 0, &o));
assert!(d0 < 40, "start differs {d0}");
let d1 = close(&frame(1.0), &render_slide(&p, 1, &o));
assert!(d1 < 40, "end differs {d1}");
// Halfway the shared words are on their way: ink between the two texts' rows.
let mid = frame(0.5);
let ink = (95..140).flat_map(|y| (0..480).map(move |x| (x, y))).filter(|&(x, y)| mid.pixel(x, y)[0] < 128).count();
assert!(ink > 50, "ink {ink}");
}
+1
View File
@@ -15,3 +15,4 @@ deckcraft-color = { workspace = true }
deckcraft-fonts = { workspace = true }
unicode-linebreak = { workspace = true }
unicode-segmentation = { workspace = true }
unicode-bidi = { workspace = true }
+152
View File
@@ -0,0 +1,152 @@
//! Bidirectional text (UAX #9) for paragraph layout.
//!
//! The pipeline is the one the algorithm prescribes: embedding levels are resolved once for the
//! whole logical paragraph (base level from `a:pPr/@rtl`); shaping and line breaking happen in
//! logical order; then each line is reordered on its own ([`line_levels`] applies rule L1,
//! [`visual_order`] rule L2). Rule L4 (mirrored brackets) is applied by the shaper, which draws
//! mirrorable characters in right-to-left items with their mirrored glyphs.
use unicode_bidi::BidiClass::{self, AL, AN, B, BN, FSI, LRE, LRI, LRO, PDF, PDI, R, RLE, RLI, RLO, S, WS};
use unicode_bidi::{BidiInfo, Level, bidi_class};
/// Does anything in the text need the full algorithm? (Strong right-to-left, Arabic numbers or
/// explicit embeddings.)
fn needs_bidi(chars: &[char]) -> bool {
chars.iter().any(|c| matches!(bidi_class(*c), R | AL | AN | LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI))
}
/// Resolved embedding level of every character of a paragraph (before the line rules), with the
/// paragraph level forced to 1 when `rtl`.
pub(crate) fn levels(text: &str, chars: &[char], rtl: bool) -> Vec<u8> {
let base = u8::from(rtl);
if !rtl && !needs_bidi(chars) {
return vec![0; chars.len()];
}
let info = BidiInfo::new(text, Some(if rtl { Level::rtl() } else { Level::ltr() }));
// `levels` is per byte; take each character's first byte.
text.char_indices().map(|(b, _)| info.levels.get(b).map(|l| l.number()).unwrap_or(base)).collect()
}
/// Whitespace-like for rule L1: reset to the paragraph level when trailing a line or preceding a
/// segment/paragraph separator.
fn l1_whitespace(c: BidiClass) -> bool {
matches!(c, WS | FSI | LRI | RLI | PDI | BN | LRE | RLE | LRO | RLO | PDF)
}
/// Rule L1 for one line: segment and paragraph separators, any whitespace run before them, and
/// whitespace at the end of the line take the paragraph level.
pub(crate) fn line_levels(chars: &[char], levels: &[u8], para_level: u8) -> Vec<u8> {
let mut out: Vec<u8> = levels.to_vec();
out.resize(chars.len(), para_level);
// Walk backwards: `trailing` is true while every character after this one (up to the line end
// or a separator) is whitespace.
let mut trailing = true;
for (i, c) in chars.iter().enumerate().rev() {
let class = bidi_class(*c);
if matches!(class, S | B) {
if let Some(l) = out.get_mut(i) {
*l = para_level;
}
trailing = true;
} else if l1_whitespace(class) {
if trailing && let Some(l) = out.get_mut(i) {
*l = para_level;
}
} else {
trailing = false;
}
}
out
}
/// Rule L2: the visual order of a line's characters (indices into `levels`, left to right). From
/// the highest level down to the lowest odd level, every maximal run at that level or higher is
/// reversed.
pub(crate) fn visual_order(levels: &[u8]) -> Vec<usize> {
let mut order: Vec<usize> = (0..levels.len()).collect();
let Some(&max) = levels.iter().max() else { return order };
let min_odd = levels.iter().copied().filter(|l| l % 2 == 1).min().unwrap_or(max.saturating_add(1));
let mut lvl = max;
while lvl >= min_odd && lvl > 0 {
let mut i = 0;
while i < order.len() {
let at = |k: usize| order.get(k).and_then(|&j| levels.get(j)).copied().unwrap_or(0);
if at(i) >= lvl {
let start = i;
while i < order.len() && at(i) >= lvl {
i += 1;
}
if let Some(run) = order.get_mut(start..i) {
run.reverse();
}
} else {
i += 1;
}
}
lvl -= 1;
}
order
}
#[cfg(test)]
mod tests {
use super::*;
fn lv(s: &str, rtl: bool) -> Vec<u8> {
let chars: Vec<char> = s.chars().collect();
levels(s, &chars, rtl)
}
#[test]
fn mixed_arabic_latin_numbers_and_brackets() {
let s = "سنة 2024 (Retrait) فقط";
let l = lv(s, true);
let chars: Vec<char> = s.chars().collect();
assert_eq!(l.len(), chars.len());
// Arabic letters at the paragraph level 1; European digits at 2; Latin at 2.
assert_eq!(l[0], 1);
assert_eq!(&l[4..8], &[2, 2, 2, 2]);
assert_eq!(&l[10..17], &[2; 7]);
assert_eq!(l[19], 1);
// Brackets around Latin inside an RTL paragraph resolve to the embedding direction (N0).
assert_eq!(l[9] % 2, 1, "( is right-to-left");
assert_eq!(l[17] % 2, 1, ") is right-to-left");
// Visual order, left to right: the Arabic words reversed, Latin and digits kept, the
// bracket characters swapped (their glyphs are mirrored back by the shaper, rule L4).
let order = visual_order(&line_levels(&chars, &l, 1));
let vis: String = order.iter().map(|&i| chars[i]).collect();
assert_eq!(vis, "طقف )Retrait( 2024 ةنس");
}
#[test]
fn ltr_paragraph_fast_path_and_embedded_rtl() {
assert_eq!(lv("Hello 123", false), vec![0; 9]);
let s = "ab سن cd";
let chars: Vec<char> = s.chars().collect();
let l = lv(s, false);
assert_eq!(l, vec![0, 0, 0, 1, 1, 0, 0, 0]);
let vis: String = visual_order(&l).iter().map(|&i| chars[i]).collect();
assert_eq!(vis, "ab نس cd");
// An RTL paragraph of Latin text: the text stays left to right inside it.
assert_eq!(lv("ab", true), vec![2, 2]);
}
#[test]
fn l1_resets_trailing_whitespace_and_tabs() {
let chars: Vec<char> = "ab\tcd ".chars().collect();
let l = line_levels(&chars, &[2, 2, 2, 2, 2, 2, 2], 1);
assert_eq!(l, vec![2, 2, 1, 2, 2, 1, 1]);
// Short level slices never panic.
assert_eq!(line_levels(&['a', 'b'], &[], 1), vec![1, 1]);
}
#[test]
fn visual_order_edge_cases() {
assert!(visual_order(&[]).is_empty());
assert_eq!(visual_order(&[0, 0]), vec![0, 1]);
assert_eq!(visual_order(&[1, 1, 1]), vec![2, 1, 0]);
// Numbers (level 2) inside RTL keep their order.
assert_eq!(visual_order(&[1, 2, 2, 1]), vec![3, 1, 2, 0]);
assert_eq!(visual_order(&[125, 125]), vec![1, 0]);
}
}
+763 -190
View File
File diff suppressed because it is too large Load Diff
+274
View File
@@ -1,4 +1,5 @@
use super::*;
use deckcraft_model::Fill;
use deckcraft_model::defaults;
use deckcraft_model::text::{Paragraph, Run};
use deckcraft_model::{LayoutType, Presentation, Xfrm};
@@ -121,3 +122,276 @@ fn numbered_list_counts() {
let l = lay(&p, &sh, 400.0, 300.0);
assert!(l.runs.len() >= 6);
}
// ---------- Bidirectional text and Arabic typography ----------
const MIXED: &str = "سنة 2024 (Retrait) فقط";
/// A one-paragraph body with no bullet, `rtl` and `align` set.
fn bidi_setup(runs: Vec<Run>, w: f64, rtl: bool, align: Align) -> (Presentation, Shape) {
let (p, mut sh) = setup("x", w, 300.0);
let t = sh.text.as_mut().unwrap();
t.paragraphs = vec![Paragraph { runs, ..Default::default() }];
let pp = &mut t.paragraphs[0].props;
pp.rtl = Some(rtl);
pp.align = Some(align);
pp.bullet = Some(Bullet::None);
pp.margin_left = Some(0.0);
pp.indent = Some(0.0);
(p, sh)
}
/// Is any face able to draw Arabic here (installed or bundled)? Shaping assertions that need
/// real Arabic glyphs are skipped without one.
fn arabic_face() -> Option<Arc<FontFace>> {
let db = FontDb::global();
let f = db.cascade(&['\u{0633}'], &[db.face("Inter", "Regular")], "Regular");
f.covers('\u{0633}').then_some(f)
}
#[test]
fn rtl_mixed_string_is_reordered_per_line() {
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
let l = lay(&p, &sh, 2000.0, 300.0);
assert_eq!(l.lines.len(), 1);
let line = &l.lines[0];
assert!(line.rtl);
let n = MIXED.chars().count();
assert_eq!(line.edges.len(), n);
assert_eq!(line.caret_x.len(), n + 1);
let mid = |k: usize| (line.edges[k].0 + line.edges[k].1) / 2.0;
// Visual order right to left: سنة | 2024 | (Retrait) | فقط.
assert!(mid(0) > mid(4), "first Arabic word right of the number");
assert!(mid(4) > mid(10), "number right of the Latin word");
assert!(mid(10) > mid(19), "Latin right of the last Arabic word");
// Inside the Arabic words, logical order runs right to left; digits and Latin left to right.
assert!(mid(0) > mid(1) && mid(1) > mid(2));
assert!(mid(4) < mid(5) && mid(5) < mid(7));
assert!(mid(10) < mid(16));
// The brackets sit outside "Retrait": "(" (logically first) on its right.
assert!(mid(9) > mid(16) && mid(17) < mid(10));
// Right-aligned at the start edge S = W - rIns; the downstream caret at 0 is that edge.
let right_edge = l.inner.x1;
assert!((line.extent.1 - right_edge).abs() < 0.5, "{} vs {right_edge}", line.extent.1);
assert!((line.caret_x[0] - line.extent.1).abs() < 0.01);
// Levels after L1: Arabic odd, digits and Latin even.
assert_eq!(line.levels[0], 1);
assert_eq!(line.levels[4], 2);
assert_eq!(line.levels[10], 2);
}
#[test]
fn rtl_paragraph_starts_at_the_right_with_the_bullet_outside() {
let (p, mut sh) = bidi_setup(vec![Run::new("مرحبا بكم")], 400.0, true, Align::Right);
let pp = &mut sh.text.as_mut().unwrap().paragraphs[0].props;
pp.bullet = Some(Bullet::Char { char: "•".into() });
pp.margin_left = Some(36.0);
pp.indent = Some(-36.0);
let l = lay(&p, &sh, 400.0, 300.0);
let line = &l.lines[0];
// Text starts marL from the right edge; the bullet hangs at S - (marL + indent) = S.
let s_edge = l.inner.x1;
assert!((line.extent.1 - (s_edge - 36.0)).abs() < 0.5, "text right {} expected {}", line.extent.1, s_edge - 36.0);
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
let bx = bullet.glyphs[0].1;
assert!(bx > line.extent.1 - 0.5, "bullet {bx} right of the text {}", line.extent.1);
assert!(bx < s_edge, "bullet inside the box");
// An LTR paragraph keeps the bullet on the left.
sh.text.as_mut().unwrap().paragraphs[0].props.rtl = Some(false);
sh.text.as_mut().unwrap().paragraphs[0].props.align = Some(Align::Left);
let l = lay(&p, &sh, 400.0, 300.0);
let bullet = l.runs.iter().find(|r| r.chars == (0, 0)).expect("bullet run");
assert!(bullet.glyphs[0].1 < l.lines[0].extent.0);
}
#[test]
fn rtl_left_and_center_alignment() {
let (p, sh) = bidi_setup(vec![Run::new("سلام")], 400.0, true, Align::Left);
let l = lay(&p, &sh, 400.0, 300.0);
assert!((l.lines[0].extent.0 - l.inner.x0).abs() < 0.5, "algn=l is flush left in RTL too");
let (p, sh) = bidi_setup(vec![Run::new("سلام")], 400.0, true, Align::Center);
let l = lay(&p, &sh, 400.0, 300.0);
let c = (l.lines[0].extent.0 + l.lines[0].extent.1) / 2.0;
assert!((c - (l.inner.x0 + l.inner.x1) / 2.0).abs() < 0.5);
}
#[test]
fn bidi_caret_stops_round_trip_through_hit_testing() {
for rtl in [true, false] {
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, rtl, if rtl { Align::Right } else { Align::Left });
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
for (x, ch, aff) in line.visual_stops() {
let (cx, ..) = l.caret_at(Pos { para: 0, ch }, aff).unwrap();
assert!((cx - x).abs() < 0.01, "stop {ch} {aff:?}: caret {cx} vs stop {x}");
}
// Clicking just inside either side of each grapheme puts the caret on that side.
for (a, b) in line.graphemes() {
let Some((x0, x1)) = line.grapheme_box(a, b) else { continue };
if x1 - x0 < 1.0 {
continue;
}
let y = (line.top + line.bottom) / 2.0;
for x in [x0 + 0.2, x1 - 0.2] {
let (pos, aff) = l.hit_affinity(Point::new(x, y));
let (cx, ..) = l.caret_at(pos, aff).unwrap();
let side = if x < (x0 + x1) / 2.0 { x0 } else { x1 };
assert!((cx - side).abs() < 0.01, "rtl={rtl} grapheme {a}: hit {x} -> {pos:?} {aff:?} caret {cx}, expected {side}");
}
}
}
}
#[test]
fn visual_movement_walks_the_line_left_to_right() {
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
let l = lay(&p, &sh, 2000.0, 300.0);
let stops = l.lines[0].visual_stops();
let (x0, ch0, aff0) = stops[0];
let (mut pos, mut aff) = (Pos { para: 0, ch: ch0 }, aff0);
let mut xs = vec![x0];
for _ in 0..200 {
let (np, na) = l.move_caret(pos, aff, true, MoveMode::Visual);
if (np, na) == (pos, aff) {
break;
}
(pos, aff) = (np, na);
xs.push(l.caret_at(pos, aff).unwrap().0);
}
assert!(xs.windows(2).all(|w| w[1] > w[0]), "moving right always moves right: {xs:?}");
let right = l.lines[0].extent.1;
assert!((xs.last().unwrap() - right).abs() < 0.01, "reaches the right edge");
// Right arrow at the right edge of an RTL paragraph's only line goes nowhere.
assert_eq!(l.move_caret(pos, aff, true, MoveMode::Visual).0, pos);
// Moving left from the right edge in an RTL paragraph is logically forward.
let (p1, _) = l.move_caret(Pos { para: 0, ch: 0 }, Affinity::Downstream, false, MoveMode::Visual);
assert_eq!(p1.ch, 1);
}
#[test]
fn logical_movement_steps_over_harakat() {
// beh + fatha + teh: the fatha belongs to the beh's grapheme.
let (p, sh) = bidi_setup(vec![Run::new("\u{0628}\u{064E}\u{062A}")], 400.0, true, Align::Right);
let l = lay(&p, &sh, 400.0, 300.0);
let (n1, _) = l.move_caret(Pos { para: 0, ch: 0 }, Affinity::Downstream, true, MoveMode::Logical);
assert_eq!(n1.ch, 2);
let (n2, _) = l.move_caret(n1, Affinity::Downstream, false, MoveMode::Logical);
assert_eq!(n2.ch, 0);
// No caret stop inside a grapheme.
assert!(l.lines[0].visual_stops().iter().all(|s| s.1 != 1));
}
#[test]
fn selection_is_the_union_of_visual_boxes() {
// LTR paragraph with an Arabic word: visually "ab ةنس cd".
let (p, sh) = bidi_setup(vec![Run::new("ab سنة cd")], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
// Logical [1, 5) = "b", " ", "س", "ن": two separate boxes on screen.
let r = l.selection_rects(Pos { para: 0, ch: 1 }, Pos { para: 0, ch: 5 });
assert_eq!(r.len(), 2, "{r:?}");
// The whole line is one box.
assert_eq!(l.selection_rects(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 9 }).len(), 1);
}
#[test]
fn paint_only_style_changes_keep_arabic_joining() {
let Some(_) = arabic_face() else {
eprintln!("skipped: no Arabic face available");
return;
};
let word = "سنة";
let glyphs = |runs: Vec<Run>| {
let (p, sh) = bidi_setup(runs, 400.0, true, Align::Right);
let l = lay(&p, &sh, 400.0, 300.0);
let mut g: Vec<(u32, f64)> = l.runs.iter().flat_map(|r| r.glyphs.iter().map(|g| (g.0, g.1))).collect();
g.sort_by(|a, b| a.1.total_cmp(&b.1));
g.into_iter().map(|g| g.0).collect::<Vec<_>>()
};
let whole = glyphs(vec![Run::new(word)]);
let mut red = Run::new("سن");
red.props.fill = Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(Rgba::from_hex("C00000").unwrap())));
let split = glyphs(vec![red, Run::new("ة")]);
assert_eq!(whole, split, "a colour change inside a word keeps the joined forms");
// And the joined forms differ from the isolated letters.
let isolated: Vec<u32> = word.chars().map(|c| arabic_face().unwrap().glyph_for(c)).collect();
assert_ne!(whole.iter().rev().copied().collect::<Vec<_>>(), isolated);
}
#[test]
fn just_low_stretches_arabic_with_kashida() {
if arabic_face().is_none() {
eprintln!("skipped: no Arabic face available");
return;
}
// Tatweel glyphs drawn, counted in each run's own face (the one the layout picked).
let tatweels = |l: &TextLayout| {
l.runs
.iter()
.map(|r| {
let tg = r.face.glyph_for('\u{0640}');
if tg == 0 { 0 } else { r.glyphs.iter().filter(|g| g.0 == tg).count() }
})
.sum::<usize>()
};
let text = "بسم الله الرحمن الرحيم نستعين به ونستغفره ونتوب إليه";
let (p, sh) = bidi_setup(vec![Run::new(text)], 220.0, true, Align::JustLow);
let l = lay(&p, &sh, 220.0, 600.0);
assert!(l.lines.len() >= 2);
let first = &l.lines[0];
let w = l.inner.width();
assert!((first.extent.1 - first.extent.0 - w).abs() < 0.5, "first line fills the box: {:?} vs {w}", first.extent);
if l.runs.iter().all(|r| r.face.glyph_for('\u{0640}') == 0) {
eprintln!("skipped: the Arabic face has no tatweel");
return;
}
assert!(tatweels(&l) > 0, "kashida drawn");
// The last line isn't stretched and sits at the start (right) edge.
let last = l.lines.last().unwrap();
assert!((last.extent.1 - l.inner.x1).abs() < 0.5);
// Plain justify uses spaces: no tatweel.
let (p, sh) = bidi_setup(vec![Run::new(text)], 220.0, true, Align::Justify);
let l = lay(&p, &sh, 220.0, 600.0);
assert_eq!(tatweels(&l), 0);
}
#[test]
fn wrapped_rtl_lines_break_in_logical_order() {
let text = "واحد اثنان ثلاثة أربعة خمسة ستة سبعة ثمانية تسعة عشرة";
let (p, sh) = bidi_setup(vec![Run::new(text)], 160.0, true, Align::Right);
let l = lay(&p, &sh, 160.0, 600.0);
assert!(l.lines.len() >= 2);
// Lines cover the text in logical order: the first line holds the first words.
assert_eq!(l.lines[0].start, 0);
for w in l.lines.windows(2) {
assert!(w[1].start >= w[0].end);
}
for line in &l.lines {
assert!(line.extent.0 >= l.inner.x0 - 0.5, "line fits: {:?}", line.extent);
assert!((line.extent.1 - l.inner.x1).abs() < 0.5, "flush right");
}
// Upstream affinity at a wrap keeps the caret on the earlier line.
let end0 = l.lines[0].end;
assert_eq!(l.line_of_affine(Pos { para: 0, ch: end0 }, Affinity::Upstream), Some(0));
}
#[test]
fn hostile_bidi_input_never_panics() {
let nasty = format!("{}\t\u{b}\u{202E}abc\u{2069}{}(]))[ ١٢٣ \u{0640}\u{064E}\u{064E}\u{200D}", "\u{202B}".repeat(200), "\u{2067}".repeat(130));
for rtl in [true, false] {
for align in [Align::Left, Align::Right, Align::Center, Align::Justify, Align::JustLow, Align::Distributed] {
let (p, sh) = bidi_setup(vec![Run::new(nasty.clone()), Run::new(MIXED)], 60.0, rtl, align);
let l = lay(&p, &sh, 60.0, 60.0);
for line in &l.lines {
assert_eq!(line.caret_x.len(), line.end - line.start + 1);
assert_eq!(line.edges.len(), line.end - line.start);
}
let _ = l.hit_affinity(Point::new(-1e9, 1e9));
let _ = l.selection_rects(Pos { para: 0, ch: 0 }, Pos { para: 0, ch: 9999 });
let mut pos = (Pos::default(), Affinity::Downstream);
for i in 0..50 {
pos = l.move_caret(pos.0, pos.1, i % 3 != 0, if i % 2 == 0 { MoveMode::Visual } else { MoveMode::Logical });
}
let _ = l.caret_at(Pos { para: 7, ch: 7 }, Affinity::Upstream);
}
}
}
+3 -2
View File
@@ -407,9 +407,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
&& let deckcraft_model::ShapeKind::Table(tb) = &sh.kind
{
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
let y0: f64 = tb.rows.iter().take(r).map(|rr| rr.height).sum();
let heights = deckcraft_engine::tools::table_rows(st, tb);
let y0: f64 = heights.iter().take(r).sum();
let x0: f64 = tb.cols.iter().take(c).sum();
let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), tb.rows.get(r).map(|rr| rr.height).unwrap_or(0.0));
let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), heights.get(r).copied().unwrap_or(0.0));
let a = x.affine();
let pts = [Point::new(x0, y0), Point::new(x0 + w, y0), Point::new(x0 + w, y0 + h), Point::new(x0, y0 + h)].map(|q| xf.to_screen(a * q));
poly(painter, pts, Stroke::new(2.0, t.accent));
+44 -2
View File
@@ -853,8 +853,7 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
ui.label(egui::RichText::new("New presentation").font(theme::bold(16.0)));
});
ui.add_space(8.0);
ui.horizontal_wrapped(|ui| {
ui.add_space(40.0);
inset_wrapped(ui, |ui| {
for th in deckcraft_model::theme::builtin_themes() {
let (r, resp) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
let tile = Rect::from_min_size(r.min, vec2(180.0, 101.0));
@@ -905,7 +904,50 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
});
}
/// Wrapped rows that all start 40 points in from the left, in line with the start screen's
/// headings. The inset is a margin on the container: an `add_space` inside `horizontal_wrapped`
/// would indent only the first row.
fn inset_wrapped(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui)) {
egui::Frame::NONE.inner_margin(egui::Margin { left: 40, ..Default::default() }).show(ui, |ui| {
ui.horizontal_wrapped(add_contents);
});
}
/// PDF bytes with the export dialog's options (web: downloaded instead of written).
fn deckcraft_pdf_bytes(doc: &deckcraft_model::Presentation, params: &serde_json::Value) -> Vec<u8> {
deckcraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::{Cell, RefCell};
/// Start screen theme tiles that wrap onto a second row keep the 40-point inset (#17).
#[test]
fn inset_wrapped_indents_every_row() {
let left = Cell::new(0.0_f32);
let tiles = RefCell::new(Vec::<Rect>::new());
let mut harness = egui_kittest::Harness::builder().with_size(vec2(500.0, 400.0)).build_ui(|ui| {
left.set(ui.max_rect().left());
tiles.borrow_mut().clear();
inset_wrapped(ui, |ui| {
for _ in 0..4 {
let (r, _) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
tiles.borrow_mut().push(r);
}
});
});
harness.run();
let tiles = tiles.borrow().clone();
let first_top = tiles.first().map_or(0.0, |r| r.top());
assert!(tiles.iter().any(|r| r.top() > first_top), "tiles should wrap onto a second row: {tiles:?}");
let mut row_top = f32::NEG_INFINITY;
for r in &tiles {
if r.top() > row_top {
row_top = r.top();
assert!((r.left() - (left.get() + 40.0)).abs() < 0.5, "row starts at {} instead of {}", r.left(), left.get() + 40.0);
}
}
}
}
+2
View File
@@ -210,6 +210,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
("Center", "format.alignCenter"),
("Align Right", "format.alignRight"),
("Justify", "format.justify"),
("-", ""),
("Right-to-Left Text Direction", "format.rtl"),
],
),
(
+52 -2
View File
@@ -51,6 +51,9 @@ struct Key {
struct Transition {
old: Option<TextureHandle>,
/// The slide left behind (Morph matches its shapes) and the Morph frame texture.
from: Option<usize>,
morph: Option<TextureHandle>,
start: f64,
kind: String,
option: String,
@@ -119,8 +122,9 @@ impl Show {
.map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0))
.unwrap_or(("none".into(), String::new(), 0.0));
let old = self.tex.as_ref().map(|(_, t)| t.clone());
let from = self.tex.as_ref().map(|(k, _)| k.slide);
self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation {
Some(Transition { old, start: now, kind, option, dur })
Some(Transition { old, from, morph: None, start: now, kind, option, dur })
} else {
None
};
@@ -288,6 +292,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
if secondary && !show.ended {
actions.push(show.state.prev(&doc));
}
// Apply input first: a step started by this click must restart the clock before it is read.
for a in actions.drain(..) {
if !show.apply(a, &doc, now) {
keep = false;
}
}
// Step clock.
let (step, playing) = (show.state.step, show.state.playing);
let t_step = if playing { now - show.step_start } else { 0.0 };
@@ -383,7 +393,47 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
Some(tex)
};
let mut drew = false;
if let (Some(tr), Some(new)) = (&show.trans, &tex) {
let morphing = show.trans.as_ref().is_some_and(|tr| tr.kind == "morph" && tr.from.is_some() && now - tr.start < tr.dur);
let tl = if morphing { Some(show.timeline(&doc).clone()) } else { None };
let morph = match (&show.trans, tl) {
(Some(tr), Some(tl)) => {
let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
let hidden_b = |id: ShapeId| {
let st = state_for(&tl, id, 0, 0.0);
!st.visible || st.opacity <= 0.0 || hidden.contains(&id)
};
tr.from
.and_then(|i| doc.slides.get(i).map(|a| (i, a)))
.zip(doc.slides.get(idx))
.map(|((i, a), b)| (i, deckcraft_anim::morph_frame(&doc, a, b, p, &hidden_b)))
}
_ => None,
};
if let (Some((from, m)), Some(tr)) = (morph, show.trans.as_mut()) {
let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() };
let threads =
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) };
let opts =
RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), state: Some(&f), threads, ..Default::default() };
let text: Vec<deckcraft_render::TextMorph> = m
.text
.iter()
.map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix })
.collect();
let img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &opts);
let ci = crate::textures::to_color_image(&img, false);
let frame = match tr.morph.take() {
Some(mut h) => {
h.set(ci, egui::TextureOptions::LINEAR);
h
}
None => ctx.load_texture("morph", ci, egui::TextureOptions::LINEAR),
};
painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
tr.morph = Some(frame);
drew = true;
ctx.request_repaint();
} else if let (Some(tr), Some(new)) = (&show.trans, &tex) {
let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
if p < 1.0 {
for l in deckcraft_anim::transition_layers(&tr.kind, &tr.option, p) {
+44
View File
@@ -0,0 +1,44 @@
# macOS DMG window
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
`Applications` link side by side. `package.sh` copies these files into the image; nothing here is
generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no
Finder scripting on CI, nothing extra installed in the signing job).
| File | What |
|---|---|
| `background.svg` | Source of the background: the app icon (`assets/app-icon/deckcraft-1024.png`, linked, not copied) cropped as a cover on the DeckCraft colour field (`#f26b1d`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. |
| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. |
| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, DeckCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. |
| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. |
## The volume name has no version
The mounted volume is called `DeckCraft`, not `DeckCraft <version>`. The layout points at the background
through an alias that includes the volume name, and Finder resolves it by that name: with a
versioned name the window keeps its size and icon positions but shows no background (tested).
The DMG file name (`deckcraft-<version>-macos-<arch>.dmg`) still carries the version.
## Rules
- **Finder draws the icon labels in black in light and dark mode** when a window has a background,
so the area under both icons stays light (Paper).
- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of
the label strip under it.
## Regenerate
Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS:
```sh
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
python3 packaging/macos/dmg/generate.py # needs resvg on PATH
```
Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the
SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background
alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG,
outline it (`usvg` from resvg converts text to paths). The window is 660 × 432 with Finder's 32 pt
title bar; the content area is 660 × 400.
This follows VectorCraft's `packaging/macos/dmg/` (storytold/vectorcraft#493).
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.
Binary file not shown.
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here).
Needs resvg on PATH and:
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing
from the machine that made them: no local paths, user or disk names, dates or volume UUIDs.
python3 packaging/macos/dmg/generate.py
"""
import datetime
import os
import shutil
import subprocess
import sys
import tempfile
from ds_store import DSStore
from mac_alias import Alias, TargetInfo, VolumeInfo
from PIL import Image, ImageCms
HERE = os.path.dirname(os.path.abspath(__file__))
# Keep in sync with package.sh (volume name) and README.md (layout).
VOLUME = "DeckCraft"
WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size
TITLE_BAR = 32
ICON_SIZE = 128
PALETTE = 16 # colours in background.tiff
ICONS = {"DeckCraft.app": (326, 205), "Applications": (574, 205)}
# A fixed date for the alias and the colour profile. Finder never matches it against the image
# (each build is a new volume): it finds the background by volume name and path.
FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc)
def render(svg, width, out):
# The SVG's text is already outlined and no fonts are loaded, so every machine draws the
# same pixels.
subprocess.run(
["resvg", "--skip-system-fonts", "-w", str(width), svg, out],
check=True,
)
with Image.open(out) as im:
return im.convert("RGB")
def quantize(im):
# No dithering: dither noise compresses badly and the artwork has flat fills.
return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE)
def srgb_profile():
# LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed.
icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes())
d = FIXED_DATE
icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0))
if icc[84:100] != bytes(16):
sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date")
return bytes(icc)
def write_tiff(svg, out):
"""1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them."""
with tempfile.TemporaryDirectory() as tmp:
one, two = (
quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2)
)
two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page
# Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette
# keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's.
one.save(
out,
"TIFF",
save_all=True,
append_images=[two],
compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate)
dpi=(72, 72),
icc_profile=srgb_profile(),
)
with Image.open(out) as im:
im.seek(1)
if im.info.get("dpi") != (144, 144):
sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)")
strips = []
for page in (0, 1):
im.seek(page)
strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts
# TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff
# leaves the padding byte after it uninitialised, so the file changed from run to run with
# identical pixels. Zero it: nothing refers to that byte.
starts = {o for o, _ in strips}
with open(out, "r+b") as f:
for o, n in strips:
if (o + n) % 2 and o + n not in starts:
f.seek(o + n)
f.write(bytes(1))
def background_alias():
# What Finder stores for /.background/background.tiff on the volume, minus anything about the
# machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable),
# the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids
# are only hints, as the volume's creation date.
volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0")
volume.posix_path = f"/Volumes/{VOLUME}"
target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0")
target.folder_name = ".background"
target.cnid_path = [25]
target.carbon_path = f"{VOLUME}:.background:\0background.tiff"
target.posix_path = "/.background/background.tiff"
return Alias(volume=volume, target=target).to_bytes()
def write_ds_store(out):
if os.path.exists(out):
os.remove(out)
with DSStore.open(out, "w+") as d:
d["."]["bwsp"] = {
"ShowStatusBar": False,
"ShowToolbar": False,
"ShowTabView": False,
"ContainerShowSidebar": False,
"ShowSidebar": False,
"WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}",
}
d["."]["icvp"] = {
"viewOptionsVersion": 1,
"backgroundType": 2,
"backgroundImageAlias": background_alias(),
"backgroundColorRed": 1.0,
"backgroundColorGreen": 1.0,
"backgroundColorBlue": 1.0,
"gridOffsetX": 0.0,
"gridOffsetY": 0.0,
"gridSpacing": 100.0,
"arrangeBy": "none",
"showIconPreview": True,
"showItemInfo": False,
"labelOnBottom": True,
"textSize": 16.0,
"iconSize": float(ICON_SIZE),
}
d["."]["vSrn"] = ("long", 1)
for name, pos in ICONS.items():
d[name]["Iloc"] = pos
def main():
if not shutil.which("resvg"):
sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)")
write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff"))
write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store"))
if __name__ == "__main__":
main()
+6 -1
View File
@@ -131,10 +131,15 @@ STAGE="$WORK/dmg"
mkdir -p "$STAGE"
ditto "$APP" "$STAGE/DeckCraft.app"
ln -s /Applications "$STAGE/Applications"
# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md).
mkdir -p "$STAGE/.background"
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
hdiutil makehybrid -hfs -hfs-volume-name "DeckCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
# The volume name has no version: .DS_Store finds the background through an alias that includes it.
hdiutil makehybrid -hfs -hfs-volume-name "DeckCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
sign "$DMG"