Compare commits

...
Author SHA1 Message Date
dependabot[bot] 2f93b2a78c chore(deps): bump the cargo-dependencies group across 1 directory with 9 updates
Bumps the cargo-dependencies group with 9 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [skrifa](https://github.com/googlefonts/fontations) | `0.47.0` | `0.49.0` |
| [harfrust](https://github.com/harfbuzz/harfrust) | `0.13.3` | `0.15.0` |
| [vello_cpu](https://github.com/linebender/vello) | `0.2.0` | `0.3.0` |
| [vello_common](https://github.com/linebender/vello) | `0.2.0` | `0.3.0` |
| [quick-xml](https://github.com/tafia/quick-xml) | `0.41.0` | `0.42.0` |
| [zip](https://github.com/zip-rs/zip2) | `3.0.0` | `9.0.5` |
| [symphonia](https://github.com/pdeljanov/Symphonia) | `0.5.5` | `0.6.1` |
| [hayro-syntax](https://github.com/LaurenzV/hayro) | `0.7.2` | `0.8.0` |
| [cpal](https://github.com/RustAudio/cpal) | `0.16.0` | `0.18.2` |



Updates `skrifa` from 0.47.0 to 0.49.0
- [Release notes](https://github.com/googlefonts/fontations/releases)
- [Changelog](https://github.com/googlefonts/fontations/blob/main/docs/RELEASE.md)
- [Commits](https://github.com/googlefonts/fontations/compare/skrifa-v0.47.0...skrifa-v0.49.0)

Updates `harfrust` from 0.13.3 to 0.15.0
- [Release notes](https://github.com/harfbuzz/harfrust/releases)
- [Changelog](https://github.com/harfbuzz/harfrust/blob/main/CHANGELOG.md)
- [Commits](https://github.com/harfbuzz/harfrust/compare/0.13.3...0.15.0)

Updates `vello_cpu` from 0.2.0 to 0.3.0
- [Release notes](https://github.com/linebender/vello/releases)
- [Changelog](https://github.com/linebender/vello/blob/v0.3.0/CHANGELOG.md)
- [Commits](https://github.com/linebender/vello/compare/v0.2.0...v0.3.0)

Updates `vello_common` from 0.2.0 to 0.3.0
- [Release notes](https://github.com/linebender/vello/releases)
- [Changelog](https://github.com/linebender/vello/blob/v0.3.0/CHANGELOG.md)
- [Commits](https://github.com/linebender/vello/compare/v0.2.0...v0.3.0)

Updates `quick-xml` from 0.41.0 to 0.42.0
- [Release notes](https://github.com/tafia/quick-xml/releases)
- [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md)
- [Commits](https://github.com/tafia/quick-xml/compare/v0.41.0...v0.42.0)

Updates `zip` from 3.0.0 to 9.0.5
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v3.0.0...v9.0.5)

Updates `symphonia` from 0.5.5 to 0.6.1
- [Release notes](https://github.com/pdeljanov/Symphonia/releases)
- [Commits](https://github.com/pdeljanov/Symphonia/compare/v0.5.5...v0.6.1)

Updates `hayro-syntax` from 0.7.2 to 0.8.0
- [Commits](https://github.com/LaurenzV/hayro/commits/hayro-syntax-v0.8.0)

Updates `cpal` from 0.16.0 to 0.18.2
- [Release notes](https://github.com/RustAudio/cpal/releases)
- [Changelog](https://github.com/RustAudio/cpal/blob/master/CHANGELOG.md)
- [Commits](https://github.com/RustAudio/cpal/compare/v0.16.0...v0.18.2)

---
updated-dependencies:
- dependency-name: cpal
  dependency-version: 0.18.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: harfrust
  dependency-version: 0.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: hayro-syntax
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: quick-xml
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: skrifa
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: symphonia
  dependency-version: 0.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: vello_common
  dependency-version: 0.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: vello_cpu
  dependency-version: 0.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: zip
  dependency-version: 9.0.5
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 10:17:58 +00:00
Dylan M. Taylor aaf6c1c38a refactor(show): split the key handler into grid, navigation and tool keys (S3776) (#1402) 2026-10-25 21:13:50 -04:00
Dylan M. Taylor 16fe4ebc65 refactor(hevc): split derive_merge into spatial, temporal, combined and zero stages (S3776) (#1401) 2026-10-25 20:55:39 -04:00
Dylan M. Taylor 430d0a91c0 refactor(hevc): split prediction_unit into merge, AMVP and store helpers (S3776) (#1400) 2026-10-25 20:55:35 -04:00
Dylan M. Taylor c93e0a0bac refactor(rtf): split the outline reader into per-construct methods (S3776) (#1399) 2026-10-25 20:55:30 -04:00
Dylan M. Taylor aa3a1baa01 refactor(av1): reduce complexity in cdef and loop restoration (S3776) (#1278) 2026-10-25 20:26:38 -04:00
Dylan M. Taylor edfee40392 refactor(av1): split inverse transform into stages and passes (S3776) (#1277) 2026-10-25 20:26:33 -04:00
Dylan M. Taylor 580b577cf2 fix(h264): use Option::as_ref instead of closure (S1612) (#1268) 2026-10-25 20:26:28 -04:00
Sofia Alves FigueredoandClaude Sonnet 5.5 230b6485d9 fix(windows): keep the shortcut icon after an update (#1114) (#1391)
* fix(windows): keep the shortcut icon after an update (#1114)

The MSI's Start menu shortcut took its icon from the WiX <Icon>, which Windows
Installer caches under %APPDATA%\Microsoft\Installer\{ProductCode}\. Every
update is a major upgrade with a new ProductCode, so the old folder is deleted.
A taskbar pin is a copy of the shortcut that the update never touches, so it
kept pointing at the deleted file and lost its icon.

Drop the Icon from the shortcut so it shows the icon embedded in deckcraft.exe,
whose path survives updates. The <Icon> stays for ARPPRODUCTICON. A test keeps
shortcuts from naming an MSI icon again.

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

* contributors: credit Sofia Alves Figueredo by real name

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

* fix(windows): make the Start menu shortcut non-advertised (#1114)

Addresses the review comment: without an MSI Icon, a non-advertised shortcut
targets the installed exe directly and takes its embedded icon.

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

* fix(windows): package.ps1 rejects a deckcraft.exe with no embedded icon (#1114)

The shortcuts now take the exe's own icon, and -SkipBuild packages an existing
exe that may come from a build without DECKCRAFT_REQUIRE_WINRES=1. Count its
icons with ExtractIconEx and stop before WiX when there are none.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 20:23:43 -04:00
Dylan M. Taylor 3418dec9e4 refactor(h264): Sonar S3776 dpb helpers (#1275)
* refactor(h264): split dpb functions (S3776)

* fix(h264): restore the MMCO 6 long-term index range check
2026-10-25 19:36:54 -04:00
Dylan M. Taylor a78d9a9a19 refactor(av1): split film grain synthesis into stages (S3776) (#1276) 2026-10-25 19:23:04 -04:00
Dylan M. Taylor 560f80abe2 refactor(h264): split derive_inter_motion and residual_cavlc (S3776) (#1272) 2026-10-25 19:22:58 -04:00
Dylan M. Taylor 2aa0310e27 refactor(av1): split OBU parsing in decoder (S3776) (#1279) 2026-10-25 19:22:35 -04:00
Dylan M. Taylor b6fd616977 refactor(h264,hevc): reduce parameter counts (S107) (#1271)
Group geometry/size/weight parameters into tuples and a PbGeom struct.
2026-10-25 19:21:54 -04:00
Dylan M. Taylor 9d23ff97b2 Merge pull request #1394 from storytold/chore/deps-safe-refresh
chore(deps): refresh serde_json, arboard, winresource, i_overlay
2026-10-25 19:00:58 -04:00
skylar 1bee1857d6 chore(deps): bump serde_json, arboard, winresource, i_overlay 2026-10-25 16:36:20 -06:00
Dylan M. Taylor 13ed8889da refactor(av1): use method path instead of closure (S1612) (#1269) 2026-10-25 18:18:36 -04:00
Dylan M. Taylor dbc5e164dd build: pin craft-update to craft-libs main (495e597) (#1388)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:50:39 -04:00
Dylan M. TaylorandCodebuff 51eef9927f build: pin craft-update to craft-libs main (495e597) (#1386)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 17:37:32 -04:00
Dylan M. Taylor 222ef368f4 Merge pull request #1273 from storytold/b-h264hevc-4
refactor(hevc): Sonar S3776 slicedec helpers
2026-10-25 16:36:14 -04:00
Dylan M. Taylor d318583867 fix(render): optimize table styles, chart lookups, line trimming, and canvas limits (#1288)
* fix(render): optimize table styles, chart lookups, line trimming, and canvas limits

* fix(render): give outer-shadow passes their own traversal budget

A shadowed group's silhouette pass shared the 100,000-visit limit with normal drawing, so a group with many children could use it up before anything visible was drawn.

* perf(render): resolve a table's style once per draw
2026-10-25 16:01:43 -04:00
Dylan M. Taylor d4945d0443 fix(codecs): harden media decoders against resource amplification and unbound state (#1285)
* fix(codecs): harden media decoders against resource amplification and unbound state

- Ogg recovery (Finding 29): Reuse capture search window buffer and bound
  cumulative recovery work to prevent quadratic re-scanning.
- Opus packet concealment (Finding 40): Skip empty stored Opus packets
  instead of triggering packet-loss concealment audio.
- Opus decoded PCM budget (Finding 63): Enforce aggregate MAX_PCM_SAMPLES
  limit before expanding planar audio samples.
- H.264 DPB reference eviction (Finding 56): Validate MMCO 6 against
  max_long_term_frame_idx and evict oldest entries when capacity is reached.
- HEVC missing reference frames (Finding 57): Share gray replacement frames
  and bound missing references to DPB buffering limits.
- AV1 show_existing_frame budget (Finding 69): Cap frame outputs per chunk
  to 32 to prevent output batch memory explosion.
- AV1 nonuniform tile bounds (Finding 89): Enforce MAX_TILE_COLS and
  MAX_TILE_ROWS limits on nonuniform tile layouts.

* fix(codecs): refine show_existing_frame limit, PCM capacity check, and buffered Ogg search
2026-10-25 15:59:10 -04:00
Dylan M. Taylor b58df1095d build: pin craft-update to craft-libs main (db19c37) (#1383)
Picks up the fix that keeps the close request pending while the app holds unsaved work, the executable-name check in the Windows update helper, and the zip 9 / dirs-free build.

Part of tracking issue storytold/craft-libs#319
2026-10-25 15:34:20 -04:00
Dylan M. Taylor b70a6fda35 feat: startup update prompt modal and direct self-updating (#1377)
* feat: startup update prompt modal and direct self-updating

Ref: storytold/craft-libs#319

* build: pin craft-update to craft-libs main (b9ba47f) and refresh the lock

* refactor(updates): drive the flow with craft-update's Updater

Results are read independent of the prompt, a dropped worker is an error, failures return to the prompt, and no update starts over unsaved work. A manual check during the startup check is honored.
2026-10-25 14:48:33 -04:00
Brandon ThomasandClaude Opus 5.5 5d9c3818a5 Tests: the Slide Size preset test opens the dialog's own combo box (egui 0.37 exposes the ribbon's too)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:49:12 -04:00
Brandon ThomasandClaude Opus 5.5 31cf44c2ff Integration: the merged PRs import what they use (#1270) and use egui 0.37's wheel source and media clock (#1350)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:45:19 -04:00
Brandon ThomasandClaude Opus 5.5 d20b08e3af Tests: drop the units test's PNG debug hook; the PDF link test's doc says only web and mail links export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:43:25 -04:00
Brandon ThomasandClaude Opus 5.5 4504495e2b Tests: one blank line between the merged engine tests (cargo fmt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:43:24 -04:00
Brandon Thomas 5e9043afa6 Merge pull request #1336
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/panes.rs
#	crates/ui-egui/src/ribbon.rs
2026-10-25 13:43:07 -04:00
Brandon Thomas f1bb876e28 Merge pull request #1334
# Conflicts:
#	crates/engine/src/cmd/edit.rs
2026-10-25 13:42:53 -04:00
Brandon Thomas 5648c4d7ec Merge pull request #1189
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-25 13:42:45 -04:00
Brandon Thomas d85e397be4 Merge pull request #1291
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-25 13:42:45 -04:00
Brandon Thomas 0e78adc675 Merge pull request #1259
# Conflicts:
#	crates/engine/src/cmd/richclip.rs
2026-10-25 13:42:29 -04:00
Brandon Thomas 556048436e Merge pull request #1221 2026-10-25 13:42:06 -04:00
Brandon Thomas 637bbcdf51 Merge pull request #1118 2026-10-25 13:42:06 -04:00
Brandon Thomas b6c1fe628c Merge pull request #1109 2026-10-25 13:42:06 -04:00
Brandon Thomas ad664b2cdd Merge pull request #1107 2026-10-25 13:42:06 -04:00
Brandon Thomas 8b2c0f456a Merge pull request #1294 2026-10-25 13:42:05 -04:00
Brandon Thomas ea4001a26e Merge pull request #1256 2026-10-25 13:42:05 -04:00
Brandon Thomas 4268cab027 Merge pull request #1246 2026-10-25 13:42:05 -04:00
Brandon Thomas c8a13561b3 Merge pull request #1331 2026-10-25 13:42:05 -04:00
Brandon Thomas 90f723918b Merge pull request #1319 2026-10-25 13:42:05 -04:00
Brandon Thomas 5f5f341d27 Merge pull request #1317 2026-10-25 13:42:05 -04:00
Brandon Thomas d217820576 Merge pull request #1315 2026-10-25 13:42:05 -04:00
Brandon Thomas d6d30bbf20 Merge pull request #1313 2026-10-25 13:42:05 -04:00
Brandon Thomas 7a9786b355 Merge pull request #1311 2026-10-25 13:42:05 -04:00
Brandon Thomas 5e2f1993c3 Merge pull request #1190 2026-10-25 13:42:05 -04:00
Brandon Thomas 24905e7057 Merge pull request #1358 2026-10-25 13:42:05 -04:00
Brandon Thomas 3c4d6e12de Merge pull request #1354 2026-10-25 13:42:05 -04:00
Brandon Thomas 7d6445e060 Merge pull request #1353 2026-10-25 13:42:05 -04:00
Brandon Thomas b21d71e648 Merge pull request #1352 2026-10-25 13:42:05 -04:00
Brandon Thomas ec274ec2fe Merge pull request #1351 2026-10-25 13:42:04 -04:00
Brandon Thomas 178edac4ac Merge pull request #1329 2026-10-25 13:42:04 -04:00
Brandon Thomas d0411d0332 Merge pull request #1304 2026-10-25 13:42:04 -04:00
Brandon Thomas f034901719 Merge pull request #1303 2026-10-25 13:42:04 -04:00
Brandon Thomas 547807c58d Merge pull request #1302 2026-10-25 13:42:04 -04:00
Brandon Thomas 5ed7844cb9 Merge pull request #1297 2026-10-25 13:42:04 -04:00
Brandon Thomas f9b564cd00 Merge pull request #1284 2026-10-25 13:42:04 -04:00
Dylan M. TaylorandClaude Sonnet 5.5 7d4c49051c refactor: explicit imports in engine/anim/geom/model/render/text/pdf/ogg modules (SonarCloud S2208) (#1270)
* refactor: explicit imports instead of glob imports in command and test modules (SonarCloud S2208)

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

* fix: geom extra imports

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

* fix: drop unused imports

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

* fix: restore explicit imports in test modules (SonarCloud S2208)

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 13:18:05 -04:00
FaabyyandClaude 52d0fcd6c7 Reuse Slides: insert into the presentation and after the slide the picker was opened for (#1103)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 13:47:23 +00:00
FaabyyandClaude 121441a079 Slides: the range anchor is held by slide id, so moving, inserting or deleting slides can't shift it (#1105)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 13:47:18 +00:00
FaabyyandClaude abdf5f337b Slides: a second Shift-click extends from the same anchor; the range clears cells and returns to the slides; a bad from is an error (#1105)
Review on #1107: read `from` with opt_usize_param, clear `sel.cells` and set
`target = Slides` as `slide.go` did, keep the anchor of the first Shift-click.
A kittest drives the Shift and Ctrl+Shift clicks in the slide sorter.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 11:50:42 +00:00
FaabyyandClaude 95a8f90d1a Reuse Slides: insert from the bytes that were listed; a failed insert keeps the dialog open (#1103)
Review on #1109: the file is read once when the list is made and the same bytes
are inserted, so a file changed meanwhile can't swap the slides; `reuse_slides`
returns whether it inserted and the dialog closes only then. A kittest drives the
dialog.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 11:50:24 +00:00
FaabyyandClaude a378c78c25 Roadmap: record sel-range-1105
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 11:50:24 +00:00
FaabyyandClaude 29bd07e1b8 Slides: Ctrl+Shift+click selects the range, also in the slide sorter (#1105)
New slide.selectRange {to, from?, add?} selects every slide between the current one (or
`from`) and `to`. Shift+click in the slide list and the slide sorter selects that range;
Ctrl+Shift+click adds it to the slides already selected. The sorter had no range selection
before, and in the list Ctrl+Shift+click used to add only the clicked slide.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 11:48:27 +00:00
NetterSilverandClaude Haiku 5.5 6523a49fb7 Reject invalid layout index in master.deleteLayout and renameLayout
Found while testing crates/engine/src/cmd/design.rs:1353 (layout_target), used by delete_layout at :1368 and rename_layout at :1356

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 09:16:09 +00:00
NetterSilverandClaude Haiku 5.5 8a730912c2 Keep auto-advance when transition.timing gets an invalid after
Found while testing crates/engine/src/cmd/transition.rs:112-115 (timing)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 07:02:08 +00:00
NetterSilverandClaude Haiku 5.5 ea72d9d712 Fix HTML paste panic on bare '&' before multi-byte text
Found while testing crates/engine/src/cmd/richclip.rs:307-308 (decode_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 06:35:45 +00:00
NetterSilverandClaude Haiku 5.5 6a24586412 Resolve OPC relationship targets that use backslash separators
Found while testing crates/pptx/src/opc.rs:295-309 (resolve, used by rels_of)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 06:29:31 +00:00
NetterSilverandClaude Haiku 5.5 9c985e6a6a Ignore signed \'hh hex escapes in RTF outline import
Found while testing crates/format/src/rtf.rs:226 (rtf_to_outline)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 06:35:40 +01:00
Marco Arnulfo ab43e4b8f2 Units: Slide Size keeps each field's exact size in points, so a preset or Portrait gives the exact slide size (#1326) 2026-10-24 23:18:01 +02:00
Marco Arnulfo a7ba879a3c Pasted shapes are renamed only when the name is already taken on the slide; new shapes skip names in use (fixes #1290) 2026-10-24 23:10:35 +02:00
Marco Arnulfo d384bbc496 Units: Preferences chooses inches or centimetres for the Format pane, the ribbon's size boxes, the Slide Size dialog and the ruler (#1326) 2026-10-24 23:02:45 +02:00
Marco Arnulfo 98ce1b35be Pasted shapes get the next automatic name instead of keeping the original's (fixes #1290) 2026-10-24 22:52:52 +02:00
Marco Arnulfo 2e54272415 Show: Ctrl+wheel zooms the slide in and out around the pointer (#1286)
Cmd+wheel on a Mac. Four notches double the zoom, up to 4x; zooming back to the whole slide leaves the zoom. The plain wheel still turns the page, and the trackpad pinch is still not a show gesture.
2026-10-24 21:56:17 +02:00
NetterSilverandClaude Haiku 5.5 8145cc1bac Treat curly apostrophe as ASCII in spell check
Found while testing crates/engine/src/spell.rs:693 (paragraph_words) and :353 (Dictionary::check)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 19:24:20 +00:00
Marco Arnulfo 9341ae0616 Shapes: Arrow: Notched Right cuts its notch as deep as presetShapeDefinitions give it, parallel to the head 2026-10-24 19:26:42 +02:00
Marco Arnulfo 9e9e13fc2c Files: saving as Outline/RTF keeps every paragraph of a slide title 2026-10-24 19:11:59 +02:00
Marco Arnulfo 1525dae1bd Files: opening an RTF outline keeps non-breaking hyphens 2026-10-24 19:08:05 +02:00
Marco Arnulfo 53bdcbafef Files: opening a Word RTF outline skips the list bullets and keeps each item's indent 2026-10-24 19:04:10 +02:00
Marco Arnulfo 9830b57fbe Shapes: a speech bubble's wedge mirrors with its tip, at 7..10 twelfths on the far side as presetShapeDefinitions give it 2026-10-24 18:50:57 +02:00
Dominik Strebel 8236a9441b docs(roadmap): status line names the 2026-10-24 progress-log entry 2026-10-24 17:16:58 +02:00
NetterSilverandClaude Haiku 5.5 61b3108be7 Reject invalid index in animation remove, move, timing and options
Found while testing crates/engine/src/cmd/animation.rs:232-246 (remove); same pattern in each_target :265 and move_anim :250

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:06:43 +02:00
Dominik Strebel c15e260794 fix(render): an emphasis tint recolours the fill behind a picture's and an object preview's transparent pixels 2026-10-24 16:48:24 +02:00
NetterSilverandClaude Haiku 5.5 744c01f66f Match style as a whole attribute name in HTML paste
Found while testing crates/engine/src/cmd/richclip.rs:230-242 (attr, used by tag_style)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 14:35:42 +00:00
NetterSilverandClaude Haiku 5.5 35f240e453 Return BadParams for overflowing start in review.spellingChange
Found while testing crates/engine/src/cmd/review.rs:424 (spelling_change)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:53:26 +00:00
KataccandClaude Opus 5.5 8bc0afaff5 Test the Language dialog's default with nothing selected
With no text or shape selected, Review › Language must apply to the
whole presentation without choosing it, and Selected text must be
disabled. The existing test still covers switching to Whole
presentation while text is selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:31:55 +03:00
KataccandClaude Opus 5.5 66e369de26 Doc comments for the language functions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:31:55 +03:00
KataccandClaude Opus 5.5 291725c78e format.languageAll: also mark each text body's own list style
A layout placeholder's a:lstStyle can carry lang/noProof, and
ed::format_all only touched runs and end marks, so such a placeholder
kept its old language. Existing list style levels of shape, table cell
and notes bodies now get the edit too; bodies without one get none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:31:55 +03:00
KataccandClaude Opus 5.5 507fdc3767 Contributors: credit Katacc by GitHub profile name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:31:53 +03:00
KataccandClaude Opus 5.5 2d3997d2e3 Review: proofing language for the whole presentation
Review › Language gets "Apply to: Selected text / Whole presentation".
The new format.languageAll command sets lang/noProof on every run in
the deck (slides, groups, table cells, notes, masters, layouts) and on
the master and default text styles, so new text inherits it. It needs
no selection and is one undo step; format.language is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:31:35 +03:00
Dominik Strebel a8f1573fba fix(pptx): an object saved as its preview picture keeps the fallback picture's fill 2026-10-24 14:29:30 +02:00
NetterSilverandClaude Haiku 5.5 53eda1e8df Reject multiple leading '#' in Rgba::from_hex
Found while testing crates/color/src/lib.rs:37

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 14:21:11 +02:00
Dominik StrebelandClaude Opus 5.5 aece097096 Render: a picture's fill shows behind its transparent pixels
PowerPoint draws a picture's own fill (p:pic/p:spPr) behind the picture, so
it shows through its transparent pixels: a synthetic deck with one
half-transparent PNG three times (no fill, an orange fill, a yellow fill
with a blue outline), exported as PDF, shows the orange and yellow boxes
behind the image. DeckCraft drew the outline but never the fill.

Old equation objects use this: an OLE object (p:oleObj, Equation.3) has a
fallback picture whose spPr gives it a coloured box behind the equation's
transparent metafile. The reader took only the picture's image as the
object's preview and dropped its fill and line.

- The shape-fill loop moves into Renderer::fill_geometry; a picture and an
  embedded object's preview paint their own explicit fill with it before
  the image (a style fill alone still draws nothing, as before).
- An embedded object without a fill or line of its own takes them from
  its fallback picture's spPr; its frame keeps its own position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:07:43 +02:00
oracola91 920925abae PDF export keeps only the links the slide show opens
A denylist can't keep up with OS protocol handlers: ms-msdt:, ms-word:
with a share path, ldap: and file:///%5C%5Chost/share still went into
exported PDFs. PDF export now writes a URL link only when safe_url
allows it, the check the slide show already uses on a click: http,
https and mailto. Links between slides are unchanged, and any other
link's text stays on the page without the link.

safe_url moves from deckcraft-anim to deckcraft-model, which both
crates use; deckcraft_anim::safe_url stays as a re-export. Local file:
and relative links are left out for now, as asked in review.
2026-10-24 13:31:06 +03:00
oracola91 274b626007 Test: every link of the unsafe-link test sits on the slide
The last links started below the 540 pt slide. They were still
exported, as zero-height annotations at the page edge, so the test
worked, but only because of that. With all 16 links on the slide the
test no longer depends on how off-page links are handled.
2026-10-24 13:27:00 +03:00
oracola91 afb52aeef3 PDF export also drops search: and search-ms: links
Windows Search's search: and search-ms: links can name a folder on
another machine through crumb=location:, and opening one connects to
that host and sends the user's NTLM credentials. They are now left out
of the PDF like the other network-share links, and the test covers both.
2026-10-24 13:27:00 +03:00
oracola91 8c4907a626 PDF export also drops smb: links
An smb://host/share link is a path to another machine too: opening it
connects to that host (macOS and Linux file managers open these links).
It is now left out of the PDF like the other network-share links, and
the test covers it.
2026-10-24 13:27:00 +03:00
oracola91 8fd1c1716f PDF export drops javascript:, vbscript:, data: and network-share links
A slide hyperlink from an untrusted deck became a PDF link whatever it
pointed at. Script and data links, and links to another machine (UNC
\\host\share, //host/share, file://host/...), are now left out of the
PDF; the text stays. On Windows, opening a network path sends the
user's NTLM credentials to that host.

Web, mail, local file: and relative links are kept. The scheme is read
the way a browser reads it: control characters dropped, leading spaces
trimmed, any case. The slide show already refuses these links
(safe_url), and cadcraft's PDF export drops script and data links.
2026-10-24 13:27:00 +03:00
NetterSilverandClaude Haiku 5.5 fb778030c9 Reject invalid shape ids in ids instead of using the selection
Found while testing crates/engine/src/cmd/mod.rs:ids_param / targets (~line 335-350)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 10:13:35 +00:00
NetterSilverandClaude Haiku 5.5 ef6819b6fd Reject '+' sign in OPC target percent-decoding
Found while testing crates/pptx/src/opc.rs:263-277 (percent_decode, used by resolve)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 09:31:47 +00:00
Dylan M. TaylorandClaude Sonnet 5.5 63a2e099a6 refactor(hevc): split slicedec functions (S3776)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 08:48:55 +00:00
SusieeTheFemboyandGLM-5.3 238b34be00 Media: a video with an unsupported codec says only its audio plays (#1237)
insert.video answered playable=true with no warning for a clip whose video
codec has no decoder (a VP8 + Vorbis WebM, say): its audio track plays, but
the slide shows an unexplained black box, which reads as "black with no
audio". The insert now warns that the video can't be played yet and only
the audio plays.

The missing audio itself is not reproducible: through the real insert +
play path the Vorbis track of a VP8 WebM decodes and sounds, on main and on
0.10.0 (the reporter's build). A regression test drives that whole path, on
a new synthesised clip-vp8.webm fixture.

Co-Authored-By: GLM-5.3 <noreply@z.ai>
2026-10-24 10:41:30 +02:00
NetterSilverandClaude Haiku 5.5 ee248bb5cb Decode numeric tab entities in HTML paste
Found while testing crates/engine/src/cmd/richclip.rs:304 (numeric_char, used by decode_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 07:18:46 +00:00
NetterSilverandClaude Sonnet 5.5 866f349fbf fix: centre linear trendline sums so offset x ranges still fit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 23:31:52 +00:00
NetterSilverandClaude Sonnet 5.5 9679c6e16f fix: Name registered table commands in errors and reject invalid cell params
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 01:13:55 +02:00
SusieeTheFemboyandClaude Opus 5.5 2bc30fbd9a H.264: assert the r clamp's endpoints directly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:42:20 +02:00
SusieeTheFemboyandGLM-5.3 57acc28df3 H.264: the inverse transforms clamp coefficients to the spec r range
The dequantisation wraps by design (huge_levels_dequantise_without_
overflow_panics), so idct4_add and idct8_add receive any i32 coefficient,
and their plain-i32 butterflies panicked a debug build with "attempt to
add with overflow" on extreme values. A dequantised r outside
-2^15..2^15-1 leaves the transforms' output unspecified (ITU-T H.264
8.5.12.1, 8.5.13.1), so those inputs are clamped to that range —
bit-exact for every conforming stream — instead of panicking.

Debug-only (release wraps), memory-safe either way; the sibling SoundCraft
decoder fixed the same class (its audit's H.264 arithmetic findings).
Extends the grouped robustness audit's codec-overflow list (#1183), which
covers the POC/MMCO/latency sites but not these transforms.

Co-Authored-By: GLM-5.3 <noreply@z.ai>
2026-10-24 00:34:06 +02:00
NetterSilverandClaude Haiku 5.5 c0128a0eb8 Use scale-relative degeneracy check in linear trendline fit
Found while testing crates/model/src/trendline.rs:281-313 (linreg)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:21:18 +02:00
NetterSilverandClaude Haiku 5.5 0ce545fcb8 Reject invalid row/col index in table row and column commands
Found while testing crates/engine/src/cmd/table.rs:234-238 (at), via crates/engine/src/cmd/mod.rs:318 (usize_param)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:46:51 +02:00
114 changed files with 4855 additions and 1736 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ artwork. The ribbon and menu icons stay original drawings.
| `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 |
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp8.webm`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / libvpx-vp9 / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
| `crates/media/tests/fixtures/damaged/*` (`ebml-header.mkv`, `ebml-position.webm`, `demux-loop.mp4`, `demux-loop.m4a`) | DeckCraft contributors | the `clip-*` and `tone*` fixtures above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged media must not panic or hang |
| `crates/media/tests/fixtures/damaged/*` (`stsc-count.m4a`, `stsz-size.m4a`) | DeckCraft contributors | `tone.m4a` above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged MP4 sample tables must not abort |
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
Generated
+710 -392
View File
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -54,16 +54,17 @@ unicode-linebreak = "0.1"
chrono = { version = "0.4", default-features = false, features = ["std"] }
# text / rendering / formats
skrifa = "0.47"
harfrust = "0.13"
vello_cpu = "0.2"
vello_common = { version = "0.2", default-features = false }
skrifa = "0.49"
harfrust = "0.15"
vello_cpu = "0.3"
vello_common = { version = "0.3", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
i_overlay = "9"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
quick-xml = "0.42"
zip = { version = "9", default-features = false, features = ["deflate"] }
# ui
egui = { version = "0.37", default-features = false }
+14
View File
@@ -3,9 +3,12 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (proofing language for the whole presentation; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -134,9 +137,12 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once |
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
| 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
| 2026-10-24 | Preferences ▸ Measurement units: inches or centimetres. The Format pane's Size and Position fields, Shape Format ▸ Size on the ribbon, the Slide Size dialog and the ruler show and take the chosen unit; the deck still stores points (#1326) |
| 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |
| 2026-10-24 | A picture's own fill shows behind its transparent pixels, and an embedded object (an old equation, `p:oleObj`) takes the fill and outline of its fallback picture, as PowerPoint for Mac draws them (synthetic probe: a half-transparent PNG on orange and on yellow with an outline); on a private 166-deck corpus 51 slides of equations on coloured boxes move closer to PowerPoint's PDF, none further |
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
@@ -146,14 +152,17 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All |
| 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) |
| 2026-10-23 | A shape with no fill and no line (a text box) no longer draws its shape-level glow as a halo round its box; the text's own glow is all PowerPoint shows |
| 2026-10-24 | Slide Show: Ctrl+wheel (Cmd+wheel on a Mac) over the slide zooms in and out around the pointer, four notches per doubling, up to 4×, as in PowerPoint; zooming back to the whole slide leaves the zoom, and the plain wheel still turns the page (#1286) |
| 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) |
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
| 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again |
| 2026-10-21 | Review › Language has *Whole presentation*: one proofing language for every text box, table cell, note, master and layout, and the default for new text (`format.languageAll`, one undo step); the per-selection language still works |
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
| 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
| 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names |
| 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) |
| 2026-10-21 | Slide list and slide sorter: Shift+click selects a range of slides and Ctrl+Shift+click adds it to the selection (`slide.selectRange`, #1105) |
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
@@ -285,13 +294,17 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
| 2026-10-24 | trivial | Progress log: Ctrl+wheel zoom during the slide show (#1286). Scores and milestone names unchanged |
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: the Columns dialog. Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option. Scores and milestone names unchanged |
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
| 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged |
| 2026-10-21 | trivial | Progress log: slide range selection (#1105). Scores and milestone names unchanged |
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
| 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
@@ -299,6 +312,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
| 2026-10-24 | trivial | Progress log: picture and embedded object fills |
| 2026-10-22 | trivial | Progress log: table text takes the master's other-text style |
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
+2 -1
View File
@@ -25,10 +25,11 @@ chrono = { workspace = true }
winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
image = { workspace = true }
cpal = "0.16"
cpal = "0.18"
[target.'cfg(target_os = "macos")'.dependencies]
# Finder double-click, Open With, Dock drops and `open -a DeckCraft deck.pptx` arrive as a
+31
View File
@@ -187,6 +187,13 @@ 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();
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::DECKCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("DeckCraft update helper failed: {e}");
std::process::exit(1);
}
return Ok(());
}
let (control_port, files, sample, show) =
match startup(std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()), std::env::args().skip(1)) {
Startup::Help => {
@@ -261,6 +268,7 @@ fn main() -> eframe::Result {
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
let mut app = SlideApp::new(Session::new(), services());
load_prefs(&mut app);
app.enable_update_checks();
// AutoRecover: reopen what a previous run left unsaved, then keep it current.
if std::env::var_os("DECKCRAFT_NO_RECOVERY").is_none() {
app.session.recovery_dir = deckcraft_engine::recovery::default_dir();
@@ -422,4 +430,27 @@ mod tests {
assert_eq!(mac, win, "macOS and Windows offer different file types");
assert_eq!(mac, linux, "macOS and Linux offer different file types");
}
/// #1114: a shortcut that names an MSI `<Icon>` shows a blank icon once an update deletes the
/// cached copy of it, and a taskbar pin (a copy of the shortcut) is never repaired. Shortcuts
/// take the icon embedded in `deckcraft.exe`; only `ARPPRODUCTICON` uses the MSI `<Icon>`.
#[test]
fn windows_shortcuts_use_the_icon_embedded_in_the_exe() {
let wxs = include_str!("../../../packaging/windows/deckcraft.wxs");
let shortcuts: Vec<&str> = wxs.split("<Shortcut").skip(1).filter_map(|s| s.split("/>").next()).collect();
assert!(!shortcuts.is_empty(), "deckcraft.wxs has no shortcut");
for shortcut in shortcuts {
assert!(!shortcut.contains("Icon="), "a shortcut names an MSI <Icon>:{shortcut}");
}
assert!(wxs.contains("<Property Id=\"ARPPRODUCTICON\""), "Apps and features keeps its icon");
}
/// The shortcuts take the exe's own icon, and `package.ps1 -SkipBuild` packages an existing
/// exe without `DECKCRAFT_REQUIRE_WINRES`, so the script has to refuse one with no icon.
#[test]
fn the_windows_package_script_rejects_an_exe_without_an_icon() {
let script = include_str!("../../../packaging/windows/package.ps1");
assert!(script.contains("ExtractIconEx"), "package.ps1 does not count the exe's icons");
assert!(script.contains("has no embedded icon"), "package.ps1 does not reject an iconless exe");
}
}
+6
View File
@@ -62,3 +62,9 @@ sync_github_name = false
[people.GhostMitra]
sync_github_name = true
[people.Katacc]
sync_github_name = true
[people.SofiaAFigueredo]
real_name = "Sofia Alves Figueredo"
+2 -12
View File
@@ -29,18 +29,8 @@ pub enum ShowAction {
Exit,
}
/// Can a link from a (possibly untrusted) deck be opened outside the show? Only web pages and
/// mail: `file:`, `javascript:`, relative paths and other schemes (OS protocol handlers) are not
/// opened on a click.
pub fn safe_url(url: &str) -> bool {
let u = url.trim();
let Some((scheme, rest)) = u.split_once(':') else { return false };
match scheme.to_ascii_lowercase().as_str() {
"http" | "https" => rest.starts_with("//") && rest.len() > 2,
"mailto" => !rest.is_empty(),
_ => false,
}
}
// Which links the show opens on a click; PDF export writes the same ones.
pub use deckcraft_model::text::safe_url;
/// What a clicked link does in the show (`ShowState::follow`).
#[derive(Clone, Debug, PartialEq, Eq)]
+4 -1
View File
@@ -7,7 +7,10 @@ use deckcraft_model::{
AnimClass, AnimStart, Animation, ColorRef, CustomShow, Presentation, Rgba, Shape, ShapeId, Slide, SlideId, TextBody, Transition, Xfrm,
};
use crate::*;
use crate::{
AnimState, Layer, LinkJump, MotionPath, ShowAction, ShowState, Source, Timeline, TriggerPlay, default_path, ease, morph_crop, morph_frame,
morph_pairs, morph_xfrm, show_order, transition_frame, transition_layers,
};
const W: f64 = 960.0;
const H: f64 = 540.0;
+1 -1
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use deckcraft_model::text::Action;
use deckcraft_model::{AnimClass, AnimStart, Animation, Presentation, Shape, ShapeId, Slide, SlideId, Transition, Xfrm};
use crate::*;
use crate::{Click, HitShape, LinkJump, ShowAction, ShowState, Target, Timeline};
fn shape(id: u32) -> Shape {
Shape { id: ShapeId(id), name: format!("S{id}"), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() }
+115 -74
View File
@@ -45,7 +45,7 @@ fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usiz
let var_str = if (var >> 6) != 0 { (31 - ((var >> 6) as u32).leading_zeros()).min(12) as i32 } else { 0 };
pri = if var != 0 { (pri * (4 + var_str) + 8) >> 4 } else { 0 };
let damping = cp.damping as i32 + coeff_shift as i32;
filter(fs, out, 0, r, c, pri, sec, damping, dir);
filter(fs, out, 0, (r, c), (pri, sec), damping, dir);
if fs.num_planes == 1 {
return true;
}
@@ -53,8 +53,8 @@ fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usiz
let sec = (cp.uv_sec[idx] << coeff_shift) as i32;
let dir = if pri == 0 { 0 } else { CDEF_UV_DIR[fs.ssx][fs.ssy][y_dir] as usize };
let damping = cp.damping as i32 + coeff_shift as i32 - 1;
filter(fs, out, 1, r, c, pri, sec, damping, dir);
filter(fs, out, 2, r, c, pri, sec, damping, dir);
filter(fs, out, 1, (r, c), (pri, sec), damping, dir);
filter(fs, out, 2, (r, c), (pri, sec), damping, dir);
true
}
@@ -124,33 +124,74 @@ fn constrain(diff: i32, threshold: i32, adj: u32) -> i32 {
if diff < 0 { -mag } else { mag }
}
#[allow(clippy::too_many_arguments)]
fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) {
if pri == 0 && sec == 0 {
// Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame.
return;
/// Tap offsets, weights and strengths of one block.
struct Taps {
pri: i32,
sec: i32,
pri_adj: u32,
sec_adj: u32,
pri_off: [isize; 2],
sec_off: [[isize; 2]; 2],
pri_taps: [i32; 2],
sec_taps: [i32; 2],
}
impl Taps {
fn new(coeff_shift: u32, (pri, sec): (i32, i32), damping: i32, dir: usize) -> Taps {
let pri_tap = ((pri >> coeff_shift) & 1) as usize;
let off = |d: usize, k: usize| CDEF_DIRECTIONS[d][k][0] as isize * PAD_STRIDE as isize + CDEF_DIRECTIONS[d][k][1] as isize;
let pri_off = [off(dir, 0), off(dir, 1)];
let d_lo = (dir + 6) & 7;
let d_hi = (dir + 2) & 7;
let sec_off = [[off(d_lo, 0), off(d_hi, 0)], [off(d_lo, 1), off(d_hi, 1)]];
let pri_taps = [CDEF_PRI_TAPS[pri_tap][0] as i32, CDEF_PRI_TAPS[pri_tap][1] as i32];
let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32];
let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 };
Taps { pri, sec, pri_adj: adj(pri), sec_adj: adj(sec), pri_off, sec_off, pri_taps, sec_taps }
}
let coeff_shift = fs.bit_depth - 8;
/// The 12 (offset, weight, strength, adjustment) taps in filtering order.
fn tap_list(&self) -> [(isize, i32, i32, u32); 12] {
let mut taps = [(0isize, 0i32, 0i32, 0u32); 12];
let mut n = 0;
for k in 0..2 {
for sign in [-1isize, 1] {
taps[n] = (sign * self.pri_off[k], self.pri_taps[k], self.pri, self.pri_adj);
taps[n + 1] = (sign * self.sec_off[k][0], self.sec_taps[k], self.sec, self.sec_adj);
taps[n + 2] = (sign * self.sec_off[k][1], self.sec_taps[k], self.sec, self.sec_adj);
n += 3;
}
}
taps
}
}
/// Position and size of a block within its plane.
#[derive(Clone, Copy)]
struct BlockRect {
x0: usize,
y0: usize,
w: usize,
h: usize,
}
/// The block with two samples of padding on every side, plus whether every sample is available
/// (CdefAvailable: its 4x4 block is inside the frame).
fn padded_block(fs: &FrameState, plane: usize, src: &Plane, rect: BlockRect) -> ([i32; PAD_STRIDE * PAD_STRIDE], bool) {
let (sub_x, sub_y) = if plane > 0 { (fs.ssx, fs.ssy) } else { (0, 0) };
let x0 = (c * 4) >> sub_x;
let y0 = (r * 4) >> sub_y;
let w = 8 >> sub_x;
let h = 8 >> sub_y;
let src = &fs.cur.planes[plane];
let (mi_rows, mi_cols) = (fs.fh.mi_rows as isize, fs.fh.mi_cols as isize);
// CdefAvailable per sample: its 4x4 block is inside the frame.
let mut buf = [CDEF_NA; PAD_STRIDE * PAD_STRIDE];
let mut all_avail = true;
for i in 0..h + 4 {
let y = (y0 + i) as isize - 2;
for i in 0..rect.h + 4 {
let y = (rect.y0 + i) as isize - 2;
let cand_r = (y << sub_y) >> 2;
if y < 0 || cand_r >= mi_rows {
all_avail = false;
continue;
}
let row = src.row(y as usize);
for j in 0..w + 4 {
let x = (x0 + j) as isize - 2;
for j in 0..rect.w + 4 {
let x = (rect.x0 + j) as isize - 2;
let cand_c = (x << sub_x) >> 2;
if x >= 0 && cand_c < mi_cols {
buf[i * PAD_STRIDE + j] = row[x as usize] as i32;
@@ -159,70 +200,70 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize,
}
}
}
let pri_tap = ((pri >> coeff_shift) & 1) as usize;
let off = |d: usize, k: usize| CDEF_DIRECTIONS[d][k][0] as isize * PAD_STRIDE as isize + CDEF_DIRECTIONS[d][k][1] as isize;
let pri_off = [off(dir, 0), off(dir, 1)];
let d_lo = (dir + 6) & 7;
let d_hi = (dir + 2) & 7;
let sec_off = [[off(d_lo, 0), off(d_hi, 0)], [off(d_lo, 1), off(d_hi, 1)]];
let pri_taps = [CDEF_PRI_TAPS[pri_tap][0] as i32, CDEF_PRI_TAPS[pri_tap][1] as i32];
let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32];
let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 };
let (pri_adj, sec_adj) = (adj(pri), adj(sec));
(buf, all_avail)
}
/// Filters one sample whose taps may be unavailable (CDEF_NA).
fn filter_sample(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &Taps, ci: isize, x: i32) -> u16 {
let mut sum = 0i32;
let mut max = x;
let mut min = x;
for k in 0..2 {
for sign in [-1isize, 1] {
let p = buf[(ci + sign * taps.pri_off[k]) as usize];
if p != CDEF_NA {
if taps.pri != 0 {
sum += taps.pri_taps[k] * constrain(p - x, taps.pri, taps.pri_adj);
}
max = max.max(p);
min = min.min(p);
}
for s in 0..2 {
let q = buf[(ci + sign * taps.sec_off[k][s]) as usize];
if q != CDEF_NA {
if taps.sec != 0 {
sum += taps.sec_taps[k] * constrain(q - x, taps.sec, taps.sec_adj);
}
max = max.max(q);
min = min.min(q);
}
}
}
}
(x + ((8 + sum - (sum < 0) as i32) >> 4)).clamp(min, max) as u16
}
fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, (r, c): (usize, usize), strengths: (i32, i32), damping: i32, dir: usize) {
let (pri, sec) = strengths;
if pri == 0 && sec == 0 {
// Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame.
return;
}
let coeff_shift = fs.bit_depth - 8;
let (sub_x, sub_y) = if plane > 0 { (fs.ssx, fs.ssy) } else { (0, 0) };
let rect = BlockRect { x0: (c * 4) >> sub_x, y0: (r * 4) >> sub_y, w: 8 >> sub_x, h: 8 >> sub_y };
let src = &fs.cur.planes[plane];
let (buf, all_avail) = padded_block(fs, plane, src, rect);
let taps = Taps::new(coeff_shift, strengths, damping, dir);
let dst = &mut out.planes[plane];
if all_avail {
// Every tap is available: whole rows at a time (constrain() with threshold 0 is 0).
let mut taps = [(0isize, 0i32, 0i32, 0u32); 12];
let mut n = 0;
for k in 0..2 {
for sign in [-1isize, 1] {
taps[n] = (sign * pri_off[k], pri_taps[k], pri, pri_adj);
taps[n + 1] = (sign * sec_off[k][0], sec_taps[k], sec, sec_adj);
taps[n + 2] = (sign * sec_off[k][1], sec_taps[k], sec, sec_adj);
n += 3;
}
}
let rows = (y0, x0, h);
match w {
8 => filter_rows::<8>(&buf, &taps, dst, rows),
_ => filter_rows::<4>(&buf, &taps, dst, rows),
let list = taps.tap_list();
let rows = (rect.y0, rect.x0, rect.h);
match rect.w {
8 => filter_rows::<8>(&buf, &list, dst, rows),
_ => filter_rows::<4>(&buf, &list, dst, rows),
}
return;
}
for i in 0..h {
for i in 0..rect.h {
// The centre sample is read even where CdefAvailable is 0 (8x8 blocks straddling an odd
// MiRows / MiCols edge).
let srow = &src.row(y0 + i)[x0..x0 + w];
let drow = &mut dst.row_mut(y0 + i)[x0..x0 + w];
for j in 0..w {
let srow = &src.row(rect.y0 + i)[rect.x0..rect.x0 + rect.w];
let drow = &mut dst.row_mut(rect.y0 + i)[rect.x0..rect.x0 + rect.w];
for j in 0..rect.w {
let ci = ((i + 2) * PAD_STRIDE + j + 2) as isize;
let x = srow[j] as i32;
let mut sum = 0i32;
let mut max = x;
let mut min = x;
for k in 0..2 {
for sign in [-1isize, 1] {
let p = buf[(ci + sign * pri_off[k]) as usize];
if p != CDEF_NA {
if pri != 0 {
sum += pri_taps[k] * constrain(p - x, pri, pri_adj);
}
max = max.max(p);
min = min.min(p);
}
for s in 0..2 {
let q = buf[(ci + sign * sec_off[k][s]) as usize];
if q != CDEF_NA {
if sec != 0 {
sum += sec_taps[k] * constrain(q - x, sec, sec_adj);
}
max = max.max(q);
min = min.min(q);
}
}
}
}
drow[j] = (x + ((8 + sum - (sum < 0) as i32) >> 4)).clamp(min, max) as u16;
drow[j] = filter_sample(&buf, &taps, ci, srow[j] as i32);
}
}
}
+80 -54
View File
@@ -405,49 +405,35 @@ impl Decoder {
r
}
/// Whether an OBU belongs to the selected operating point (spec 7.5: OBUs of other layers are dropped).
fn in_operating_point(&self, obu: &ObuUnit) -> bool {
if obu.obu_type != OBU_SEQUENCE_HEADER_T
&& obu.obu_type != OBU_TEMPORAL_DELIMITER_T
&& obu.ext
&& let Some(seq) = &self.seq
&& seq.op_idc != 0
{
let idc = seq.op_idc;
let in_t = (idc >> obu.temporal_id) & 1;
let in_s = (idc >> (obu.spatial_id + 8)) & 1;
if in_t == 0 || in_s == 0 {
return false;
}
}
true
}
fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> {
let mut tu = 0usize;
let mut pos = 0;
while pos < data.len() {
let h = data[pos];
if h & 0x80 != 0 {
return Err(Error::Invalid("obu_forbidden_bit"));
let obu = parse_obu_unit(data, pos)?;
pos = obu.end;
if !self.in_operating_point(&obu) {
continue;
}
let obu_type = ((h >> 3) & 0xf) as u32;
let ext = h & 4 != 0;
let has_size = h & 2 != 0;
let mut p = pos + 1;
let (mut temporal_id, mut spatial_id) = (0, 0);
if ext {
let e = *data.get(p).ok_or(Error::Truncated)?;
temporal_id = (e >> 5) as u32;
spatial_id = ((e >> 3) & 3) as u32;
p += 1;
}
let size = if has_size {
let (v, n) = leb128(data.get(p..).ok_or(Error::Truncated)?)?;
p += n;
v as usize
} else {
data.len().checked_sub(p).ok_or(Error::Truncated)?
};
let end = p.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
let payload = &data[p..end];
pos = end;
if obu_type != OBU_SEQUENCE_HEADER_T
&& obu_type != OBU_TEMPORAL_DELIMITER_T
&& ext
&& let Some(seq) = &self.seq
&& seq.op_idc != 0
{
let idc = seq.op_idc;
let in_t = (idc >> temporal_id) & 1;
let in_s = (idc >> (spatial_id + 8)) & 1;
if in_t == 0 || in_s == 0 {
continue;
}
}
match obu_type {
let (payload, spatial_id) = (obu.payload, obu.spatial_id);
match obu.obu_type {
OBU_SEQUENCE_HEADER_T => {
let s = SequenceHeader::parse(payload)?;
self.seq = Some(s);
@@ -457,24 +443,11 @@ impl Decoder {
tu += 1;
}
OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => {
if self.seen_frame_header {
// frame_header_copy(): identical to the active header
if obu_type != OBU_FRAME_T {
continue;
if let Some(o) = self.frame_obu(&obu)? {
if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 {
return Err(Error::Invalid("too many frame outputs in chunk"));
}
}
let mut r = BitReader::new(payload);
let shown = self.frame_header(&mut r, temporal_id, spatial_id)?;
if let Some(o) = shown {
out.push((o, tu, spatial_id));
continue;
}
if obu_type == OBU_FRAME_T {
r.byte_align();
let rest = payload.get(r.byte_pos()..).ok_or(Error::Truncated)?;
if let Some(o) = self.tile_group(rest)? {
out.push((o, tu, spatial_id));
}
}
}
OBU_TILE_GROUP_T => {
@@ -489,6 +462,22 @@ impl Decoder {
Ok(())
}
/// A frame header or frame OBU: returns the picture it completes, if any.
fn frame_obu(&mut self, obu: &ObuUnit) -> Result<Option<Output>> {
// frame_header_copy(): identical to the active header
if self.seen_frame_header && obu.obu_type != OBU_FRAME_T {
return Ok(None);
}
let mut r = BitReader::new(obu.payload);
let shown = self.frame_header(&mut r, obu.temporal_id, obu.spatial_id)?;
if shown.is_some() || obu.obu_type != OBU_FRAME_T {
return Ok(shown);
}
r.byte_align();
let rest = obu.payload.get(r.byte_pos()..).ok_or(Error::Truncated)?;
self.tile_group(rest)
}
/// frame_header_obu( ): returns the output for show_existing_frame.
fn frame_header(&mut self, r: &mut BitReader, temporal_id: u32, spatial_id: u32) -> Result<Option<Output>> {
let seq = self.seq.clone().ok_or(Error::Invalid("frame before sequence header"))?;
@@ -1014,6 +1003,43 @@ mod tests {
}
}
/// One OBU: its header fields, payload and the position just past it.
struct ObuUnit<'a> {
obu_type: u32,
ext: bool,
temporal_id: u32,
spatial_id: u32,
payload: &'a [u8],
end: usize,
}
fn parse_obu_unit(data: &[u8], pos: usize) -> Result<ObuUnit<'_>> {
let h = data[pos];
if h & 0x80 != 0 {
return Err(Error::Invalid("obu_forbidden_bit"));
}
let obu_type = ((h >> 3) & 0xf) as u32;
let ext = h & 4 != 0;
let has_size = h & 2 != 0;
let mut p = pos + 1;
let (mut temporal_id, mut spatial_id) = (0, 0);
if ext {
let e = *data.get(p).ok_or(Error::Truncated)?;
temporal_id = (e >> 5) as u32;
spatial_id = ((e >> 3) & 3) as u32;
p += 1;
}
let size = if has_size {
let (v, n) = leb128(data.get(p..).ok_or(Error::Truncated)?)?;
p += n;
v as usize
} else {
data.len().checked_sub(p).ok_or(Error::Truncated)?
};
let end = p.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
Ok(ObuUnit { obu_type, ext, temporal_id, spatial_id, payload: &data[p..end], end })
}
#[cfg(test)]
mod guard_tests {
use super::*;
+313 -167
View File
@@ -55,21 +55,79 @@ impl Rng {
}
}
pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader) {
let _ = seq;
let bd = pic.bit_depth as u32;
let mono = pic.mono_chrome;
let num_planes = if mono { 1 } else { 3 };
let (sub_x, sub_y) = (pic.subsampling_x as usize, pic.subsampling_y as usize);
let w = pic.width as usize;
let h = pic.height as usize;
let grain_center = 128i32 << (bd - 8);
let grain_min = -grain_center;
let grain_max = (256i32 << (bd - 8)) - 1 - grain_center;
/// Picture-level parameters shared by the grain synthesis stages.
struct GrainCtx<'a> {
g: &'a FilmGrainParams,
bd: u32,
mono: bool,
sub_x: usize,
sub_y: usize,
w: usize,
h: usize,
grain_min: i32,
grain_max: i32,
}
// Generate grain (7.18.3.3).
let mut rng = Rng(g.grain_seed);
let shift = 12 - bd + g.grain_scale_shift;
impl GrainCtx<'_> {
fn num_planes(&self) -> usize {
if self.mono { 1 } else { 3 }
}
fn grain_shift(&self) -> u32 {
12 - self.bd + self.g.grain_scale_shift
}
fn ar_shift(&self) -> u32 {
self.g.ar_coeff_shift_minus_6 + 6
}
fn lag(&self) -> i32 {
self.g.ar_coeff_lag as i32
}
fn stripe_w(&self) -> usize {
self.w + 64
}
}
type GrainTemplate = Vec<[i32; 82]>;
/// The luma, Cb and Cr grain templates (7.18.3.3).
struct Templates {
luma: GrainTemplate,
cb: GrainTemplate,
cr: GrainTemplate,
}
impl Templates {
fn plane(&self, plane: usize) -> &GrainTemplate {
match plane {
0 => &self.luma,
1 => &self.cb,
_ => &self.cr,
}
}
}
fn luma_ar_sum(g: &FilmGrainParams, luma: &[[i32; 82]], lag: i32, y: usize, x: usize) -> i32 {
let mut s = 0;
let mut pos = 0;
'outer: for dr in -lag..=0 {
for dc in -lag..=lag {
if dr == 0 && dc == 0 {
break 'outer;
}
let c = g.ar_coeffs_y_plus_128[pos] as i32 - 128;
s += luma[(y as i32 + dr) as usize][(x as i32 + dc) as usize] * c;
pos += 1;
}
}
s
}
fn luma_grain(c: &GrainCtx, rng: &mut Rng) -> GrainTemplate {
let g = c.g;
let shift = c.grain_shift();
let mut luma = vec![[0i32; 82]; 73];
for row in luma.iter_mut() {
for v in row.iter_mut() {
@@ -77,77 +135,89 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
*v = round2(gv, shift);
}
}
let ar_shift = g.ar_coeff_shift_minus_6 + 6;
let lag = g.ar_coeff_lag as i32;
let ar_shift = c.ar_shift();
let lag = c.lag();
for y in 3..73 {
for x in 3..82 - 3 {
let mut s = 0;
let mut pos = 0;
'outer: for dr in -lag..=0 {
for dc in -lag..=lag {
if dr == 0 && dc == 0 {
break 'outer;
}
let c = g.ar_coeffs_y_plus_128[pos] as i32 - 128;
s += luma[(y as i32 + dr) as usize][(x as i32 + dc) as usize] * c;
pos += 1;
}
}
luma[y][x] = (luma[y][x] + round2(s, ar_shift)).clamp(grain_min, grain_max);
}
}
let chroma_w = if sub_x != 0 { 44 } else { 82 };
let chroma_h = if sub_y != 0 { 38 } else { 73 };
let mut cb = vec![[0i32; 82]; 73];
let mut cr = vec![[0i32; 82]; 73];
if !mono {
for (arr, seed, on) in [(&mut cb, 0xb524u32, g.num_cb_points > 0), (&mut cr, 0x49d8, g.num_cr_points > 0)] {
rng.0 = g.grain_seed ^ seed;
for row in arr.iter_mut().take(chroma_h) {
for v in row.iter_mut().take(chroma_w) {
let gv = if on || g.chroma_scaling_from_luma { GAUSSIAN_SEQUENCE[rng.next(11) as usize] as i32 } else { 0 };
*v = round2(gv, shift);
}
}
}
for y in 3..chroma_h {
for x in 3..chroma_w - 3 {
let mut s0 = 0;
let mut s1 = 0;
let mut pos = 0;
'outer: for dr in -lag..=0 {
for dc in -lag..=lag {
let c0 = g.ar_coeffs_cb_plus_128[pos] as i32 - 128;
let c1 = g.ar_coeffs_cr_plus_128[pos] as i32 - 128;
if dr == 0 && dc == 0 {
if g.num_y_points > 0 {
let mut l = 0;
let lx = ((x - 3) << sub_x) + 3;
let ly = ((y - 3) << sub_y) + 3;
for i in 0..=sub_y {
for j in 0..=sub_x {
l += luma[ly + i][lx + j];
}
}
let l = round2(l, (sub_x + sub_y) as u32);
s0 += l * c0;
s1 += l * c1;
}
break 'outer;
}
let (yy, xx) = ((y as i32 + dr) as usize, (x as i32 + dc) as usize);
s0 += cb[yy][xx] * c0;
s1 += cr[yy][xx] * c1;
pos += 1;
}
}
cb[y][x] = (cb[y][x] + round2(s0, ar_shift)).clamp(grain_min, grain_max);
cr[y][x] = (cr[y][x] + round2(s1, ar_shift)).clamp(grain_min, grain_max);
}
let s = luma_ar_sum(g, &luma, lag, y, x);
luma[y][x] = (luma[y][x] + round2(s, ar_shift)).clamp(c.grain_min, c.grain_max);
}
}
luma
}
// Scaling lookup (7.18.3.4).
/// The luma contribution to the chroma autoregressive filter at chroma position (y, x).
fn chroma_luma_term(c: &GrainCtx, luma: &[[i32; 82]], y: usize, x: usize) -> i32 {
let mut l = 0;
let lx = ((x - 3) << c.sub_x) + 3;
let ly = ((y - 3) << c.sub_y) + 3;
for i in 0..=c.sub_y {
for j in 0..=c.sub_x {
l += luma[ly + i][lx + j];
}
}
round2(l, (c.sub_x + c.sub_y) as u32)
}
fn chroma_ar_sums(c: &GrainCtx, t: &Templates, y: usize, x: usize) -> (i32, i32) {
let g = c.g;
let lag = c.lag();
let mut s0 = 0;
let mut s1 = 0;
let mut pos = 0;
'outer: for dr in -lag..=0 {
for dc in -lag..=lag {
let c0 = g.ar_coeffs_cb_plus_128[pos] as i32 - 128;
let c1 = g.ar_coeffs_cr_plus_128[pos] as i32 - 128;
if dr == 0 && dc == 0 {
if g.num_y_points > 0 {
let l = chroma_luma_term(c, &t.luma, y, x);
s0 += l * c0;
s1 += l * c1;
}
break 'outer;
}
let (yy, xx) = ((y as i32 + dr) as usize, (x as i32 + dc) as usize);
s0 += t.cb[yy][xx] * c0;
s1 += t.cr[yy][xx] * c1;
pos += 1;
}
}
(s0, s1)
}
/// Chroma grain templates; zero for monochrome pictures.
fn chroma_grain(c: &GrainCtx, rng: &mut Rng, luma: GrainTemplate) -> Templates {
let g = c.g;
let mut t = Templates { luma, cb: vec![[0i32; 82]; 73], cr: vec![[0i32; 82]; 73] };
if c.mono {
return t;
}
let shift = c.grain_shift();
let ar_shift = c.ar_shift();
let chroma_w = if c.sub_x != 0 { 44 } else { 82 };
let chroma_h = if c.sub_y != 0 { 38 } else { 73 };
for (arr, seed, on) in [(&mut t.cb, 0xb524u32, g.num_cb_points > 0), (&mut t.cr, 0x49d8, g.num_cr_points > 0)] {
rng.0 = g.grain_seed ^ seed;
for row in arr.iter_mut().take(chroma_h) {
for v in row.iter_mut().take(chroma_w) {
let gv = if on || g.chroma_scaling_from_luma { GAUSSIAN_SEQUENCE[rng.next(11) as usize] as i32 } else { 0 };
*v = round2(gv, shift);
}
}
}
for y in 3..chroma_h {
for x in 3..chroma_w - 3 {
let (s0, s1) = chroma_ar_sums(c, &t, y, x);
t.cb[y][x] = (t.cb[y][x] + round2(s0, ar_shift)).clamp(c.grain_min, c.grain_max);
t.cr[y][x] = (t.cr[y][x] + round2(s1, ar_shift)).clamp(c.grain_min, c.grain_max);
}
}
t
}
/// Scaling lookup tables (7.18.3.4), one per plane.
fn scaling_luts(g: &FilmGrainParams, num_planes: usize) -> [[i32; 256]; 3] {
let mut lut = [[0i32; 256]; 3];
for (plane, l) in lut.iter_mut().enumerate().take(num_planes) {
let (n, xs, ys) = if plane == 0 || g.chroma_scaling_from_luma {
@@ -159,101 +229,141 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
};
*l = scaling_lut(n, xs, ys);
}
let scale_lut = |plane: usize, index: i32| -> i32 {
let sh = bd - 8;
let x = index >> sh;
let rem = index - (x << sh);
if bd == 8 || x == 255 {
lut[plane][x as usize]
} else {
let start = lut[plane][x as usize];
let end = lut[plane][x as usize + 1];
start + round2((end - start) * rem, sh)
}
};
lut
}
// Noise stripes (7.18.3.5).
let stripe_w = w + 64;
let n_stripes = (h.div_ceil(2)).div_ceil(16).max(1);
let mut stripes: Vec<[Vec<i32>; 3]> = (0..n_stripes).map(|_| [vec![0; 34 * stripe_w], vec![0; 34 * stripe_w], vec![0; 34 * stripe_w]]).collect();
let overlap = g.overlap_flag;
for (luma_num, stripe) in stripes.iter_mut().enumerate() {
let mut r = g.grain_seed;
r ^= (((luma_num * 37 + 178) & 255) << 8) as u32;
r ^= ((luma_num * 173 + 105) & 255) as u32;
rng.0 = r;
let mut x = 0;
while x < w.div_ceil(2) {
let rand = rng.next(8) as usize;
let off_x = rand >> 4;
let off_y = rand & 15;
for (plane, s) in stripe.iter_mut().enumerate().take(num_planes) {
let psx = if plane > 0 { sub_x } else { 0 };
let psy = if plane > 0 { sub_y } else { 0 };
let pox = if psx != 0 { 6 + off_x } else { 9 + off_x * 2 };
let poy = if psy != 0 { 6 + off_y } else { 9 + off_y * 2 };
let src = match plane {
0 => &luma,
1 => &cb,
_ => &cr,
fn scale_lookup(lut: &[[i32; 256]; 3], bd: u32, plane: usize, index: i32) -> i32 {
let sh = bd - 8;
let x = index >> sh;
let rem = index - (x << sh);
if bd == 8 || x == 255 {
lut[plane][x as usize]
} else {
let start = lut[plane][x as usize];
let end = lut[plane][x as usize + 1];
start + round2((end - start) * rem, sh)
}
}
/// Fills one plane of a noise stripe from the grain template at stripe column `x`.
fn fill_stripe_plane(c: &GrainCtx, src: &GrainTemplate, s: &mut [i32], plane: usize, offsets: (usize, usize), x: usize) {
let (off_x, off_y) = offsets;
let stripe_w = c.stripe_w();
let overlap = c.g.overlap_flag;
let psx = if plane > 0 { c.sub_x } else { 0 };
let psy = if plane > 0 { c.sub_y } else { 0 };
let pox = if psx != 0 { 6 + off_x } else { 9 + off_x * 2 };
let poy = if psy != 0 { 6 + off_y } else { 9 + off_y * 2 };
for i in 0..(34 >> psy) {
for j in 0..(34 >> psx) {
let mut gv = src[poy + i][pox + j];
let (idx, blend) = if psx == 0 { (i * stripe_w + x * 2 + j, j < 2) } else { (i * stripe_w + x + j, j == 0) };
if blend && overlap && x > 0 {
let old = s[idx];
gv = if psx != 0 {
old * 23 + gv * 22
} else if j == 0 {
old * 27 + gv * 17
} else {
old * 17 + gv * 27
};
for i in 0..(34 >> psy) {
for j in 0..(34 >> psx) {
let mut gv = src[poy + i][pox + j];
if psx == 0 {
let idx = i * stripe_w + x * 2 + j;
if j < 2 && overlap && x > 0 {
let old = s[idx];
gv = if j == 0 { old * 27 + gv * 17 } else { old * 17 + gv * 27 };
gv = round2(gv, 5).clamp(grain_min, grain_max);
}
s[idx] = gv;
} else {
let idx = i * stripe_w + x + j;
if j == 0 && overlap && x > 0 {
let old = s[idx];
gv = round2(old * 23 + gv * 22, 5).clamp(grain_min, grain_max);
}
s[idx] = gv;
}
}
}
gv = round2(gv, 5).clamp(c.grain_min, c.grain_max);
}
x += 16;
s[idx] = gv;
}
}
let noise_at = |plane: usize, y: usize, x: usize| -> i32 {
let psy = if plane > 0 { sub_y } else { 0 };
}
/// Noise stripes (7.18.3.5).
struct Stripes {
data: Vec<[Vec<i32>; 3]>,
stripe_w: usize,
overlap: bool,
sub_y: usize,
grain_min: i32,
grain_max: i32,
}
impl Stripes {
fn build(c: &GrainCtx, t: &Templates, rng: &mut Rng) -> Stripes {
let g = c.g;
let stripe_w = c.stripe_w();
let n_stripes = (c.h.div_ceil(2)).div_ceil(16).max(1);
let mut data: Vec<[Vec<i32>; 3]> = (0..n_stripes).map(|_| [vec![0; 34 * stripe_w], vec![0; 34 * stripe_w], vec![0; 34 * stripe_w]]).collect();
for (luma_num, stripe) in data.iter_mut().enumerate() {
let mut r = g.grain_seed;
r ^= (((luma_num * 37 + 178) & 255) << 8) as u32;
r ^= ((luma_num * 173 + 105) & 255) as u32;
rng.0 = r;
let mut x = 0;
while x < c.w.div_ceil(2) {
let rand = rng.next(8) as usize;
let offsets = (rand >> 4, rand & 15);
for (plane, s) in stripe.iter_mut().enumerate().take(c.num_planes()) {
fill_stripe_plane(c, t.plane(plane), s, plane, offsets, x);
}
x += 16;
}
}
Stripes { data, stripe_w, overlap: g.overlap_flag, sub_y: c.sub_y, grain_min: c.grain_min, grain_max: c.grain_max }
}
fn noise_at(&self, plane: usize, y: usize, x: usize) -> i32 {
let psy = if plane > 0 { self.sub_y } else { 0 };
let luma_num = y >> (5 - psy);
let i = y - (luma_num << (5 - psy));
let mut gv = stripes[luma_num][plane][i * stripe_w + x];
let mut gv = self.data[luma_num][plane][i * self.stripe_w + x];
if psy == 0 {
if i < 2 && luma_num > 0 && overlap {
let old = stripes[luma_num - 1][plane][(i + 32) * stripe_w + x];
if i < 2 && luma_num > 0 && self.overlap {
let old = self.data[luma_num - 1][plane][(i + 32) * self.stripe_w + x];
gv = if i == 0 { old * 27 + gv * 17 } else { old * 17 + gv * 27 };
gv = round2(gv, 5).clamp(grain_min, grain_max);
gv = round2(gv, 5).clamp(self.grain_min, self.grain_max);
}
} else if i < 1 && luma_num > 0 && overlap {
let old = stripes[luma_num - 1][plane][(i + 16) * stripe_w + x];
gv = round2(old * 23 + gv * 22, 5).clamp(grain_min, grain_max);
} else if i < 1 && luma_num > 0 && self.overlap {
let old = self.data[luma_num - 1][plane][(i + 16) * self.stripe_w + x];
gv = round2(old * 23 + gv * 22, 5).clamp(self.grain_min, self.grain_max);
}
gv
};
}
}
// Blend (7.18.3.5).
let (min_value, max_luma, max_chroma) = if g.clip_to_restricted_range {
let ml = 235 << (bd - 8);
(16 << (bd - 8), ml, if pic.matrix_coefficients as usize == MC_IDENTITY { ml } else { 240 << (bd - 8) })
} else {
let m = (256 << (bd - 8)) - 1;
(0, m, m)
};
let pix_max = (1i32 << bd) - 1;
let scaling_shift = g.grain_scaling_minus_8 + 8;
if !mono {
/// Output clipping range and scaling shift for the blend step (7.18.3.5).
struct BlendRange {
min_value: i32,
max_luma: i32,
max_chroma: i32,
pix_max: i32,
scaling_shift: u32,
}
impl BlendRange {
fn new(c: &GrainCtx, matrix_coefficients: usize) -> BlendRange {
let bd = c.bd;
let (min_value, max_luma, max_chroma) = if c.g.clip_to_restricted_range {
let ml = 235 << (bd - 8);
(16 << (bd - 8), ml, if matrix_coefficients == MC_IDENTITY { ml } else { 240 << (bd - 8) })
} else {
let m = (256 << (bd - 8)) - 1;
(0, m, m)
};
BlendRange { min_value, max_luma, max_chroma, pix_max: (1i32 << bd) - 1, scaling_shift: c.g.grain_scaling_minus_8 + 8 }
}
}
struct BlendEnv<'a> {
c: &'a GrainCtx<'a>,
lut: &'a [[i32; 256]; 3],
stripes: &'a Stripes,
range: &'a BlendRange,
}
impl BlendEnv<'_> {
fn blend_chroma(&self, py: &[u16], pu: &mut [u16], pv: &mut [u16]) {
let c = self.c;
let g = c.g;
let (w, h, sub_x, sub_y, bd) = (c.w, c.h, c.sub_x, c.sub_y, c.bd);
let cw = (w + sub_x) >> sub_x;
let ch = (h + sub_y) >> sub_y;
let [py, pu, pv] = &mut pic.planes;
for y in 0..ch {
for x in 0..cw {
let lx = x << sub_x;
@@ -272,26 +382,62 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
avg
} else {
let combined = avg * (luma_mult as i32 - 128) + orig * (mult as i32 - 128);
((combined >> 6) + ((offset as i32 - 256) << (bd - 8))).clamp(0, pix_max)
((combined >> 6) + ((offset as i32 - 256) << (bd - 8))).clamp(0, self.range.pix_max)
};
let noise = round2(scale_lut(plane, merged) * noise_at(plane, y, x), scaling_shift);
out[y * cw + x] = (orig + noise).clamp(min_value, max_chroma) as u16;
let noise = round2(scale_lookup(self.lut, bd, plane, merged) * self.stripes.noise_at(plane, y, x), self.range.scaling_shift);
out[y * cw + x] = (orig + noise).clamp(self.range.min_value, self.range.max_chroma) as u16;
}
}
}
}
if g.num_y_points > 0 {
let py = &mut pic.planes[0];
fn blend_luma(&self, py: &mut [u16]) {
let (w, h) = (self.c.w, self.c.h);
for y in 0..h {
for x in 0..w {
let orig = py[y * w + x] as i32;
let noise = round2(scale_lut(0, orig) * noise_at(0, y, x), scaling_shift);
py[y * w + x] = (orig + noise).clamp(min_value, max_luma) as u16;
let noise = round2(scale_lookup(self.lut, self.c.bd, 0, orig) * self.stripes.noise_at(0, y, x), self.range.scaling_shift);
py[y * w + x] = (orig + noise).clamp(self.range.min_value, self.range.max_luma) as u16;
}
}
}
}
pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader) {
let _ = seq;
let bd = pic.bit_depth as u32;
let grain_center = 128i32 << (bd - 8);
let c = GrainCtx {
g,
bd,
mono: pic.mono_chrome,
sub_x: pic.subsampling_x as usize,
sub_y: pic.subsampling_y as usize,
w: pic.width as usize,
h: pic.height as usize,
grain_min: -grain_center,
grain_max: (256i32 << (bd - 8)) - 1 - grain_center,
};
// Generate grain (7.18.3.3).
let mut rng = Rng(g.grain_seed);
let luma = luma_grain(&c, &mut rng);
let templates = chroma_grain(&c, &mut rng, luma);
let lut = scaling_luts(g, c.num_planes());
let stripes = Stripes::build(&c, &templates, &mut rng);
// Blend (7.18.3.5).
let range = BlendRange::new(&c, pic.matrix_coefficients as usize);
let env = BlendEnv { c: &c, lut: &lut, stripes: &stripes, range: &range };
if !c.mono {
let [py, pu, pv] = &mut pic.planes;
env.blend_chroma(py, pu, pv);
}
if g.num_y_points > 0 {
env.blend_luma(&mut pic.planes[0]);
}
}
#[cfg(test)]
mod tests {
use super::*;
+6
View File
@@ -906,6 +906,9 @@ impl FrameHeader {
let mut widest = 0;
let mut start = 0;
while start < sb_cols {
if t.mi_col_starts.len() >= MAX_TILE_COLS {
return Err(Error::Invalid("tile_cols exceeds MAX_TILE_COLS"));
}
t.mi_col_starts.push(start << sb_shift);
let max_width = (sb_cols - start).min(max_tile_width_sb);
let size = r.ns(max_width)? + 1;
@@ -923,6 +926,9 @@ impl FrameHeader {
let max_tile_height_sb = (max_tile_area_sb / widest.max(1)).max(1);
let mut start = 0;
while start < sb_rows {
if t.mi_row_starts.len() >= MAX_TILE_ROWS {
return Err(Error::Invalid("tile_rows exceeds MAX_TILE_ROWS"));
}
t.mi_row_starts.push(start << sb_shift);
let max_height = (sb_rows - start).min(max_tile_height_sb);
let size = r.ns(max_height)? + 1;
+113 -78
View File
@@ -73,57 +73,70 @@ impl LrCtx<'_> {
}
}
/// Position and size of a (at most 4x4) restoration block within its plane.
#[derive(Clone, Copy)]
struct LrBlock {
x: i32,
y: i32,
w: i32,
h: i32,
}
/// Loop restoration (7.17): returns LrFrame given UpscaledCurrFrame and UpscaledCdefFrame.
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf) -> FrameBuf {
let fh = &fs.fh;
let mut lr = cdef.clone();
let up_w = fh.upscaled_width as i32;
let fr_h = fh.frame_height as i32;
let bd = fs.bit_depth;
for plane in 0..fs.num_planes {
let rtype_frame = fh.lr.frame_restoration_type[plane];
if rtype_frame == RESTORE_NONE as u8 {
if fs.fh.lr.frame_restoration_type[plane] == RESTORE_NONE as u8 {
continue;
}
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
let unit_rows = fs.lr_unit_rows[plane] as i32;
let unit_cols = fs.lr_unit_cols[plane] as i32;
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
let mut row = 0;
while row * 4 < fr_h {
let luma_y = row * 4;
let stripe_num = (luma_y + 8) / 64;
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
let mut col = 0;
while col * 4 < up_w {
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
let x = (col * 4) >> sub_x;
let y = (row * 4) >> sub_y;
let w = (4 >> sub_x).min(plane_end_x - x + 1);
let h = (4 >> sub_y).min(plane_end_y - y + 1);
if w > 0 && h > 0 {
let ui = (unit_row * unit_cols + unit_col) as usize;
match fs.lr.lr_type[plane][ui] {
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
_ => {}
}
}
col += 1;
}
row += 1;
}
restore_plane(fs, cur, cdef, &mut lr.planes[plane], plane);
}
lr
}
#[allow(clippy::too_many_arguments)]
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
fn restore_plane(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf, out: &mut Plane, plane: usize) {
let fh = &fs.fh;
let up_w = fh.upscaled_width as i32;
let fr_h = fh.frame_height as i32;
let bd = fs.bit_depth;
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
let unit_rows = fs.lr_unit_rows[plane] as i32;
let unit_cols = fs.lr_unit_cols[plane] as i32;
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
let mut row = 0;
while row * 4 < fr_h {
let luma_y = row * 4;
let stripe_num = (luma_y + 8) / 64;
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
let mut col = 0;
while col * 4 < up_w {
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
let x = (col * 4) >> sub_x;
let y = (row * 4) >> sub_y;
let w = (4 >> sub_x).min(plane_end_x - x + 1);
let h = (4 >> sub_y).min(plane_end_y - y + 1);
if w > 0 && h > 0 {
let ui = (unit_row * unit_cols + unit_col) as usize;
let blk = LrBlock { x, y, w, h };
match fs.lr.lr_type[plane][ui] {
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, out, (plane, ui), blk, bd),
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, out, (plane, ui), blk, bd),
_ => {}
}
}
col += 1;
}
row += 1;
}
}
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, (plane, ui): (usize, usize), blk: LrBlock, bd: u32) {
let LrBlock { x, y, w, h } = blk;
let round0: u32 = if bd == 12 { 5 } else { 3 };
let round1: u32 = if bd == 12 { 9 } else { 11 };
let coef = &fs.lr.lr_wiener[plane][ui];
@@ -166,13 +179,13 @@ fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize
}
}
#[allow(clippy::too_many_arguments)]
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, (plane, ui): (usize, usize), blk: LrBlock, bd: u32) {
let LrBlock { x, y, w, h } = blk;
let set = fs.lr.lr_sgr_set[plane][ui] as usize;
let mut flt0 = [[0i32; 4]; 4];
let mut flt1 = [[0i32; 4]; 4];
box_filter(ctx, x, y, w, h, set, 0, bd, &mut flt0);
box_filter(ctx, x, y, w, h, set, 1, bd, &mut flt1);
box_filter(ctx, blk, (set, 0), bd, &mut flt0);
box_filter(ctx, blk, (set, 1), bd, &mut flt1);
let xqd = fs.lr.lr_sgr_xqd[plane][ui];
let w0 = xqd[0] as i32;
let w1 = xqd[1] as i32;
@@ -198,8 +211,56 @@ fn round2_64(x: i64, n: u32) -> i64 {
if n == 0 { x } else { (x + (1i64 << (n - 1))) >> n }
}
#[allow(clippy::too_many_arguments)]
fn box_filter(ctx: &LrCtx, x: i32, y: i32, w: i32, h: i32, set: usize, pass: usize, bd: u32, f: &mut [[i32; 4]; 4]) {
/// Box sums of the samples within `r` of (x, y): (sum of squares, sum).
fn box_sums(ctx: &LrCtx, x: i32, y: i32, r: i32) -> (i64, i64) {
let mut a = 0i64;
let mut b = 0i64;
for dy in -r..=r {
for dx in -r..=r {
let c = ctx.sample(x + dx, y + dy) as i64;
a += c * c;
b += c;
}
}
(a, b)
}
/// Self-guided filter constants of one pass.
struct SgrConsts {
n: i64,
s: i64,
one_over_n: i64,
}
/// (A, B) coefficients of one sample from its box sums.
fn sgr_coefficients(a: i64, b: i64, k: &SgrConsts, bd: u32) -> (i64, i64) {
let a = round2_64(a, 2 * (bd - 8));
let d = round2_64(b, bd - 8);
let p = (a * k.n - d * d).max(0);
let z = round2_64(p * k.s, SGRPROJ_MTABLE_BITS as u32);
let a2 = if z >= 255 {
256
} else if z == 0 {
1
} else {
((z << SGRPROJ_SGR_BITS) + z / 2) / (z + 1)
};
let b2 = ((1i64 << SGRPROJ_SGR_BITS) - a2) * b * k.one_over_n;
(a2, round2_64(b2, SGRPROJ_RECIP_BITS as u32))
}
fn box_weight(pass: usize, i: i32, dx: i32, dy: i32) -> i64 {
if pass == 0 {
if ((i + dy) & 1) != 0 { if dx == 0 { 6 } else { 5 } } else { 0 }
} else if dx == 0 || dy == 0 {
4
} else {
3
}
}
fn box_filter(ctx: &LrCtx, blk: LrBlock, (set, pass): (usize, usize), bd: u32, f: &mut [[i32; 4]; 4]) {
let LrBlock { x, y, w, h } = blk;
let r = SGR_PARAMS[set][pass * 2] as i32;
if r == 0 {
return;
@@ -207,35 +268,15 @@ fn box_filter(ctx: &LrCtx, x: i32, y: i32, w: i32, h: i32, set: usize, pass: usi
let eps = SGR_PARAMS[set][pass * 2 + 1] as i64;
let n = ((2 * r + 1) * (2 * r + 1)) as i64;
let n2e = n * n * eps;
let s = ((1i64 << SGRPROJ_MTABLE_BITS) + n2e / 2) / n2e;
let one_over_n = ((1i64 << SGRPROJ_RECIP_BITS) + n / 2) / n;
let k = SgrConsts { n, s: ((1i64 << SGRPROJ_MTABLE_BITS) + n2e / 2) / n2e, one_over_n: ((1i64 << SGRPROJ_RECIP_BITS) + n / 2) / n };
let mut a_arr = [[0i64; 6]; 6];
let mut b_arr = [[0i64; 6]; 6];
for i in -1..h + 1 {
for j in -1..w + 1 {
let mut a = 0i64;
let mut b = 0i64;
for dy in -r..=r {
for dx in -r..=r {
let c = ctx.sample(x + j + dx, y + i + dy) as i64;
a += c * c;
b += c;
}
}
let a = round2_64(a, 2 * (bd - 8));
let d = round2_64(b, bd - 8);
let p = (a * n - d * d).max(0);
let z = round2_64(p * s, SGRPROJ_MTABLE_BITS as u32);
let a2 = if z >= 255 {
256
} else if z == 0 {
1
} else {
((z << SGRPROJ_SGR_BITS) + z / 2) / (z + 1)
};
let b2 = ((1i64 << SGRPROJ_SGR_BITS) - a2) * b * one_over_n;
let (a, b) = box_sums(ctx, x + j, y + i, r);
let (a2, b2) = sgr_coefficients(a, b, &k, bd);
a_arr[(i + 1) as usize][(j + 1) as usize] = a2;
b_arr[(i + 1) as usize][(j + 1) as usize] = round2_64(b2, SGRPROJ_RECIP_BITS as u32);
b_arr[(i + 1) as usize][(j + 1) as usize] = b2;
}
}
for i in 0..h {
@@ -245,13 +286,7 @@ fn box_filter(ctx: &LrCtx, x: i32, y: i32, w: i32, h: i32, set: usize, pass: usi
let mut b = 0i64;
for dy in -1..=1i32 {
for dx in -1..=1i32 {
let weight = if pass == 0 {
if ((i + dy) & 1) != 0 { if dx == 0 { 6 } else { 5 } } else { 0 }
} else if dx == 0 || dy == 0 {
4
} else {
3
};
let weight = box_weight(pass, i, dx, dy);
a += weight * a_arr[(i + dy + 1) as usize][(j + dx + 1) as usize];
b += weight * b_arr[(i + dy + 1) as usize][(j + dx + 1) as usize];
}
+48 -4
View File
@@ -104,7 +104,7 @@ impl<const L: usize> Lane for [i32; L] {
}
#[inline(always)]
fn neg(self) -> Self {
self.map(|v| v.wrapping_neg())
self.map(i32::wrapping_neg)
}
#[inline(always)]
fn mul_round12(self, m: i32) -> Self {
@@ -165,6 +165,15 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
for i in 0..len {
t[i] = copy[brev(n, i)];
}
inverse_dct_stage1(t, n, r);
inverse_dct_stage2(t, n, r);
inverse_dct_stage3(t, n, r);
inverse_dct_stage4(t, n, r);
inverse_dct_stage5(t, n, r);
inverse_dct_stage6(t, n, r);
}
fn inverse_dct_stage1<T: Lane>(t: &mut [T], n: u32, r: u32) {
if n == 6 {
for i in 0..16 {
bf(t, 32 + i, 63 - i, 63 - 4 * brev(4, i) as i32, false);
@@ -190,6 +199,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
hd(t, 16 + 2 * i, 17 + 2 * i, i & 1 == 1, r);
}
}
}
fn inverse_dct_stage2<T: Lane>(t: &mut [T], n: u32, r: u32) {
if n == 6 {
for i in 0..4 {
for j in 0..2 {
@@ -221,6 +233,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
}
}
}
}
fn inverse_dct_stage3<T: Lane>(t: &mut [T], n: u32, r: u32) {
for i in 0..2 {
bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0);
}
@@ -248,6 +263,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
}
}
}
}
fn inverse_dct_stage4<T: Lane>(t: &mut [T], n: u32, r: u32) {
for i in 0..2 {
hd(t, i, 3 - i, false, r);
}
@@ -273,6 +291,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
}
}
}
}
fn inverse_dct_stage5<T: Lane>(t: &mut [T], n: u32, r: u32) {
if n >= 3 {
for i in 0..4 {
hd(t, i, 7 - i, false, r);
@@ -300,6 +321,9 @@ pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
hd(t, i, 15 - i, false, r);
}
}
}
fn inverse_dct_stage6<T: Lane>(t: &mut [T], n: u32, r: u32) {
if n >= 5 {
for i in 0..4 {
bf(t, 27 - i, 20 + i, 32, true);
@@ -531,10 +555,9 @@ pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz:
let col_clamp = (bit_depth + 6).max(16);
let rk = row_kind(tx_type);
let ck = col_kind(tx_type);
let mut t = [0i32; 64];
let rect2 = log2w.abs_diff(log2h) == 1;
let p = Tx2d { w, h, log2w, log2h, row_shift, col_shift, row_clamp, col_clamp, rk, ck, rect2 };
if !lossless && bit_depth <= 10 {
let p = Tx2d { w, h, log2w, log2h, row_shift, col_shift, row_clamp, col_clamp, rk, ck, rect2 };
if w >= 8 && h >= 8 {
transform_lanes::<8>(dequant, residual, &p);
} else {
@@ -542,6 +565,13 @@ pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz:
}
return;
}
scalar_row_pass(dequant, residual, &p, lossless);
scalar_col_pass(residual, &p, lossless);
}
fn scalar_row_pass(dequant: &[i32], residual: &mut [i32], p: &Tx2d, lossless: bool) {
let (w, h, log2w, row_shift, row_clamp, col_clamp, rk, rect2) = (p.w, p.h, p.log2w, p.row_shift, p.row_clamp, p.col_clamp, p.rk, p.rect2);
let mut t = [0i32; 64];
let in_w = w.min(32);
for i in 0..h {
// Every 1D transform maps an all-zero row to zeros (each step is linear with
@@ -572,6 +602,11 @@ pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz:
residual[i * w + j] = round2(t[j], row_shift).clamp(lo, hi);
}
}
}
fn scalar_col_pass(residual: &mut [i32], p: &Tx2d, lossless: bool) {
let (w, h, log2h, col_shift, col_clamp, ck) = (p.w, p.h, p.log2h, p.col_shift, p.col_clamp, p.ck);
let mut t = [0i32; 64];
for j in 0..w {
for i in 0..h {
t[i] = residual[i * w + j];
@@ -617,11 +652,16 @@ fn transform_1d<T: Lane>(t: &mut [T], kind: Kind1d, n: u32, r: u32) {
/// The 2D inverse transform with `L` rows (row pass) or columns (column pass) at a time;
/// bit-identical to the one-at-a-time process for bit depths up to 10.
fn transform_lanes<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx2d) {
let mut t = [[0i32; L]; 64];
lanes_row_pass(dequant, residual, p, &mut t);
lanes_col_pass(residual, p, &mut t);
}
fn lanes_row_pass<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx2d, t: &mut [[i32; L]; 64]) {
let (w, h) = (p.w, p.h);
let in_w = w.min(32);
let lo = -(1i32 << (p.col_clamp - 1));
let hi = (1i32 << (p.col_clamp - 1)) - 1;
let mut t = [[0i32; L]; 64];
let mut i0 = 0;
while i0 < h {
// Rows without coefficients (and rows 32..63) transform to zeros.
@@ -649,6 +689,10 @@ fn transform_lanes<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx
}
i0 += L;
}
}
fn lanes_col_pass<const L: usize>(residual: &mut [i32], p: &Tx2d, t: &mut [[i32; L]; 64]) {
let (w, h) = (p.w, p.h);
let mut j0 = 0;
while j0 < w {
for i in 0..h {
+11 -1
View File
@@ -34,7 +34,8 @@ impl Rgba {
}
/// `RRGGBB` or `#RRGGBB` (also `RRGGBBAA`).
pub fn from_hex(s: &str) -> Option<Self> {
let s = s.trim().trim_start_matches('#');
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
@@ -757,6 +758,15 @@ pub fn is_system_name(name: &str) -> bool {
mod tests {
use super::*;
#[test]
fn from_hex_rejects_multiple_hashes() {
assert_eq!(Rgba::from_hex("##FF0000"), None);
assert_eq!(Rgba::from_hex("####FF0000"), None);
assert_eq!(Rgba::from_hex("#FF0000"), Some(Rgba::rgb(255, 0, 0)));
assert_eq!(Rgba::from_hex("FF0000"), Some(Rgba::rgb(255, 0, 0)));
assert_eq!(Rgba::from_hex("#FF000080"), Some(Rgba::rgba(255, 0, 0, 128)));
}
#[test]
fn from_hex_rejects_signs() {
assert_eq!(Rgba::from_hex("+12345"), None);
+27 -4
View File
@@ -5,7 +5,9 @@ use std::sync::Arc;
use deckcraft_model::anim::{ANIMATIONS, AnimClass, AnimStart, Animation, TextBuild, animation_info};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, always, bad, bool_or, bool_param, cmd, has_selection, has_slide, id_param, ok, opt_usize_param, str_param, targets, usize_param,
};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -230,7 +232,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let index = usize_param(p, "index");
let index = opt_usize_param(p, "index", "animation.remove")?;
let ids = if index.is_none() { targets(s, p)? } else { vec![] };
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.remove", "no slide"))?);
@@ -247,7 +249,7 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
}
fn move_anim(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").ok_or_else(|| bad("animation.move", "missing `index`"))?;
let i = opt_usize_param(p, "index", "animation.move")?.ok_or_else(|| bad("animation.move", "missing `index`"))?;
let to = usize_param(p, "to").ok_or_else(|| bad("animation.move", "missing `to`"))?;
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.move", "no slide"))?);
@@ -263,7 +265,7 @@ fn move_anim(s: &mut Session, p: &Value) -> Result<Value> {
}
fn each_target(s: &mut Session, p: &Value, f: impl Fn(&mut Animation)) -> Result<Value> {
let index = usize_param(p, "index");
let index = opt_usize_param(p, "index", "animation")?;
let ids = if index.is_none() { targets(s, p)? } else { vec![] };
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation", "no slide"))?);
@@ -567,4 +569,25 @@ mod tests {
assert_eq!(triggers(&s), vec![None], "the trigger was inside the deleted group");
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 1);
}
#[test]
fn invalid_index_is_an_error_not_the_selection() {
let mut s = Session::with_new();
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
let id = r.get("id").unwrap().as_u64().unwrap();
s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id]})).unwrap();
s.select(|_, sel| {
sel.shapes = vec![deckcraft_model::ShapeId(id as u32)];
})
.unwrap();
for bad_index in [json!(-1), json!(1.5), json!("x")] {
for cmd in ["animation.remove", "animation.timing", "animation.options"] {
let r = s.execute(cmd, &json!({"index": bad_index, "duration": 100}));
assert!(r.is_err(), "{cmd} accepted index {bad_index}: {r:?}");
}
let r = s.execute("animation.move", &json!({"index": bad_index, "to": 0}));
assert!(r.is_err(), "animation.move accepted index {bad_index}: {r:?}");
assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1);
}
}
}
+4 -1
View File
@@ -4,7 +4,10 @@ use deckcraft_geom::{Point, Rect, Xfrm};
use deckcraft_model::{Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, edit_geometry, has_doc, has_selection, has_slide, has_two, id_param, ok, str_param, targets,
usize_param, with_param, xfrm_of,
};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -9,7 +9,7 @@ use deckcraft_model::{MediaId, ShapeId, ShapeKind};
use image::{GrayImage, Rgba, RgbaImage};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, has_selection, str_param, targets};
use crate::{Result, Session};
/// The effects offered: `a14` element name and label.
+6 -1
View File
@@ -8,7 +8,7 @@ use std::collections::VecDeque;
use deckcraft_model::{ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, f64_or, has_selection, targets};
use crate::{Result, Session};
/// Larger pictures are refused rather than matted slowly (16 megapixels).
@@ -184,7 +184,12 @@ fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use deckcraft_model::{ShapeId, ShapeKind};
use serde_json::json;
use super::*;
use crate::Session;
use crate::cmd::base64_encode;
/// A red disc on a white-to-grey gradient.
fn disc() -> image::RgbaImage {
+1 -1
View File
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use crate::*;
use crate::Session;
fn deck() -> Session {
let mut s = Session::with_new();
+1 -1
View File
@@ -8,7 +8,7 @@ use deckcraft_model::style::PictureFill;
use deckcraft_model::{MediaId, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, f64_param, has_doc, targets};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+27 -5
View File
@@ -10,7 +10,10 @@ use deckcraft_model::theme::{builtin_color_schemes, builtin_font_schemes, builti
use deckcraft_model::{Background, Fill, PhType, Presentation, Shape, ShapeId, defaults};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, NO_ID_SPACE, always, bad, bool_or, bool_param, cmd, color_param, color_value, f64_param, has_doc, has_slide, ok, opt_usize_param,
rect_param, str_param, usize_param, with_param,
};
use crate::{Result, Session, Target};
pub fn specs() -> Vec<CommandSpec> {
@@ -1344,17 +1347,17 @@ fn insert_layout(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn layout_target(s: &Session, p: &Value) -> Result<(usize, usize)> {
fn layout_target(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> {
let st = s.doc()?;
let (m, l) = match st.selection.target {
Target::Layout { master, layout } => (master, layout),
_ => (master_index(&st.doc, &st.selection), 0),
};
Ok((usize_param(p, "master").unwrap_or(m), usize_param(p, "layout").unwrap_or(l)))
Ok((opt_usize_param(p, "master", cmd)?.unwrap_or(m), opt_usize_param(p, "layout", cmd)?.unwrap_or(l)))
}
fn rename_layout(s: &mut Session, p: &Value) -> Result<Value> {
let (mi, li) = layout_target(s, p)?;
let (mi, li) = layout_target(s, p, "master.renameLayout")?;
let name = str_param(p, "name").ok_or_else(|| bad("master.renameLayout", "missing `name`"))?.to_string();
s.edit(|doc, _| {
let l =
@@ -1366,7 +1369,7 @@ fn rename_layout(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete_layout(s: &mut Session, p: &Value) -> Result<Value> {
let (mi, li) = layout_target(s, p)?;
let (mi, li) = layout_target(s, p, "master.deleteLayout")?;
s.edit(|doc, sel| {
let id = doc.masters.get(mi).and_then(|m| m.layouts.get(li)).map(|l| l.id).ok_or_else(|| bad("master.deleteLayout", "no such layout"))?;
if doc.slides.iter().any(|x| x.layout == id) {
@@ -1619,6 +1622,25 @@ mod tests {
}
}
#[test]
fn layout_commands_reject_invalid_index() {
let mut s = Session::with_new();
s.execute("master.insertLayout", &json!({})).unwrap();
let count = |s: &Session| s.doc().unwrap().doc.masters.iter().map(|m| m.layouts.len()).sum::<usize>();
let names = |s: &Session| s.doc().unwrap().doc.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.name.clone())).collect::<Vec<_>>();
let n = count(&s);
let before = names(&s);
for bad in [json!(-1), json!(1.5)] {
assert!(s.execute("master.deleteLayout", &json!({"layout": bad})).is_err());
assert!(s.execute("master.deleteLayout", &json!({"master": bad})).is_err());
assert!(s.execute("master.renameLayout", &json!({"layout": bad, "name": "x"})).is_err());
assert_eq!(count(&s), n);
assert_eq!(names(&s), before);
}
s.execute("master.deleteLayout", &json!({})).unwrap();
assert_eq!(count(&s), n - 1);
}
#[test]
fn civil_dates() {
assert_eq!(super::civil(0), (1970, 1, 1));
+15 -1
View File
@@ -7,7 +7,10 @@ use deckcraft_model::text::TextBody;
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, NO_ID_SPACE, bad, base64_encode, bool_or, can_redo, can_undo, cmd, has_doc, has_selection, has_slide, id_param, ids_param, ok,
richclip, str_param, targets, xfrm_of,
};
use crate::{HistoryEntry, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -318,7 +321,18 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let mut shapes = clip.shapes.clone();
reid("edit.paste", doc, &mut shapes)?;
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
let mut taken = super::current_names(doc, sel);
for sh in &mut shapes {
// Only a name already on the slide is changed (two shapes with one name are what the
// issue is about); otherwise it stays, as Morph pairs shapes across slides by name.
if taken.contains(&sh.name)
&& let Some((base, n)) = sh.name.rsplit_once(' ')
&& !n.is_empty()
&& n.bytes().all(|b| b.is_ascii_digit())
{
sh.name = super::insert::first_free_name(base, &taken);
}
taken.insert(sh.name.clone());
if let Some(x) = sh.xfrm.as_mut() {
x.x += off;
x.y += off;
+1 -1
View File
@@ -7,7 +7,7 @@ use deckcraft_media::MediaInfo;
use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, str_param, usize_param};
use crate::{DocState, EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -6,7 +6,7 @@ use deckcraft_model::text::RunKind;
use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+75 -5
View File
@@ -6,10 +6,15 @@ use deckcraft_model::resolve;
use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir};
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape, ShapeKind};
use serde_json::{Value, json};
use std::sync::Arc;
use super::text::{format_body, format_para, format_run};
use super::*;
use super::{
CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, color_param, edit_shapes, f64_or, f64_param, has_doc, has_text,
has_text_or_shapes, ids_param, ok, str_param,
};
use crate::{Result, Session};
use deckcraft_model::ShapeId;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -60,6 +65,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_or_shapes,
language
),
cmd!(
"format.languageAll",
"Language for All Text",
["Review", "Language"],
None,
"{lang?, noProof?: bool} (proofing language of every text in the presentation: slides, tables, notes, masters and layouts, and the default for new text; one undo step)",
has_doc,
language_all
),
cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight),
cmd!(
"format.case",
@@ -406,16 +420,17 @@ fn text_transparency(s: &mut Session, p: &Value) -> Result<Value> {
r.fill = Some(f);
})
}
fn language(s: &mut Session, p: &Value) -> Result<Value> {
/// The `lang` and `noProof` params of the language commands, checked: a run-props setter.
fn language_edit(id: &str, p: &Value) -> Result<impl Fn(&mut RunProps) + Send + Sync + 'static> {
let lang = str_param(p, "lang").map(str::to_string);
let no_proof = bool_param(p, "noProof");
if lang.is_none() && no_proof.is_none() {
return Err(bad("format.language", "give `lang` or `noProof`"));
return Err(bad(id, "give `lang` or `noProof`"));
}
if lang.as_deref().is_some_and(|l| l.is_empty() || l.len() > 35 || !l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) {
return Err(bad("format.language", "`lang` is a language tag such as en-US"));
return Err(bad(id, "`lang` is a language tag such as en-US"));
}
format_run(s, &move |r| {
Ok(move |r: &mut RunProps| {
if let Some(l) = &lang {
r.lang = Some(l.clone());
}
@@ -424,6 +439,61 @@ fn language(s: &mut Session, p: &Value) -> Result<Value> {
}
})
}
/// Review › Language on the selected text (or the selected shapes' text).
fn language(s: &mut Session, p: &Value) -> Result<Value> {
let f = language_edit("format.language", p)?;
format_run(s, &f)
}
/// Every run in the deck, and the text styles new text inherits from, in one language.
fn language_all(s: &mut Session, p: &Value) -> Result<Value> {
let f = language_edit("format.languageAll", p)?;
/// Runs, end marks and the body's own list style levels (never creating one).
fn body(t: &mut deckcraft_model::text::TextBody, f: &dyn Fn(&mut RunProps)) {
ed::format_all(t, f);
t.list_style.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
}
/// Every text body in `list`: shapes, table cells and groups (nested at most 64 deep).
fn shapes(list: &mut [Shape], f: &dyn Fn(&mut RunProps), depth: usize) {
if depth > 64 {
return;
}
for sh in list {
if let Some(t) = &mut sh.text {
body(t, f);
}
match &mut sh.kind {
deckcraft_model::ShapeKind::Table(t) => t.rows.iter_mut().flat_map(|r| &mut r.cells).for_each(|c| body(&mut c.text, f)),
deckcraft_model::ShapeKind::Group { children, .. } => shapes(children, f, depth + 1),
_ => {}
}
}
}
/// A master or presentation text style's levels: what new text inherits.
fn styles(l: &mut deckcraft_model::text::ListStyle, f: &dyn Fn(&mut RunProps)) {
// Level 1 always, so a deck without a default text style gets one.
if l.level(0).is_none() {
l.set(0, Default::default());
}
l.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run));
}
s.edit(|doc, _| {
for sl in &mut doc.slides {
let sl = Arc::make_mut(sl);
shapes(&mut sl.shapes, &f, 0);
body(&mut sl.notes, &f);
}
let extra = [doc.notes_master.as_mut(), doc.handout_master.as_mut()];
for m in doc.masters.iter_mut().chain(extra.into_iter().flatten()) {
let m = Arc::make_mut(m);
shapes(&mut m.shapes, &f, 0);
m.layouts.iter_mut().for_each(|l| shapes(&mut l.shapes, &f, 0));
[&mut m.title_style, &mut m.body_style, &mut m.other_style].into_iter().for_each(|l| styles(l, &f));
}
styles(&mut doc.default_text_style, &f);
Ok(())
})?;
ok()
}
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color");
format_run(s, &move |r| r.highlight = c.clone())
+1 -1
View File
@@ -8,7 +8,7 @@ use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, cmd, has_slide, str_param, usize_param, xfrm_of};
use crate::{Result, Selection, Session};
pub fn specs() -> Vec<CommandSpec> {
+50 -25
View File
@@ -8,7 +8,10 @@ use deckcraft_model::text::{AutoFit, RunKind, TextBody};
use deckcraft_model::{Chart, ColorRef, Fill, Geom, Line, MediaClip, Shape, ShapeId, ShapeKind, ShapeStyle, Table};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, NO_ID_SPACE, always, bad, base64_decode, base64_encode, bool_or, bool_param, cmd, color_value, edit_shapes, has_doc, has_selection,
has_slide, has_text_or_shapes, ok, rect_param, str_param, usize_param, xfrm_of,
};
use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -149,27 +152,38 @@ fn default_rect(s: &Session, w: f64, h: f64) -> Xfrm {
Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h)
}
/// The word an automatic shape name starts with ("Rectangle" in "Rectangle 3").
pub(crate) fn auto_name_base(shape: &Shape) -> String {
match &shape.kind {
ShapeKind::Picture { .. } => "Picture".to_string(),
ShapeKind::Table(_) => "Table".to_string(),
ShapeKind::Chart(_) => "Chart".to_string(),
ShapeKind::Media(m) if m.video => "Video".to_string(),
ShapeKind::Media(_) => "Audio".to_string(),
ShapeKind::Model3d { .. } => "3D Model".to_string(),
_ if shape.text_box => "TextBox".to_string(),
_ => shape
.geom
.preset_name()
.and_then(deckcraft_geom::preset::info)
.map(|i| i.label.split(':').next().unwrap_or(i.label).trim().to_string())
.unwrap_or_else(|| "Shape".into()),
}
}
/// "{base} n" with the first `n` from the shape count + 1 that no shape on the slide already uses,
/// so a gap left by a deleted shape never makes a second shape with the same name.
pub(crate) fn first_free_name(base: &str, taken: &std::collections::HashSet<String>) -> String {
(taken.len() + 1..).map(|n| format!("{base} {n}")).find(|c| !taken.contains(c)).unwrap_or_default()
}
pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bool) -> Result<ShapeId> {
s.edit(|doc, sel| {
shape.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
if shape.name.is_empty() {
let base = match &shape.kind {
ShapeKind::Picture { .. } => "Picture".to_string(),
ShapeKind::Table(_) => "Table".to_string(),
ShapeKind::Chart(_) => "Chart".to_string(),
ShapeKind::Media(m) if m.video => "Video".to_string(),
ShapeKind::Media(_) => "Audio".to_string(),
ShapeKind::Model3d { .. } => "3D Model".to_string(),
_ if shape.text_box => "TextBox".to_string(),
_ => shape
.geom
.preset_name()
.and_then(deckcraft_geom::preset::info)
.map(|i| i.label.split(':').next().unwrap_or(i.label).trim().to_string())
.unwrap_or_else(|| "Shape".into()),
};
let n = super::current_shapes(doc, sel).map(|v| v.len()).unwrap_or(0) + 1;
shape.name = format!("{base} {n}");
let base = auto_name_base(&shape);
let taken = super::current_names(doc, sel);
shape.name = first_free_name(&base, &taken);
}
let id = shape.id;
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?;
@@ -1278,14 +1292,25 @@ fn media(s: &mut Session, p: &Value, video: bool) -> Result<Value> {
}
Err(_) => (0, 0, 0, false),
};
let warning = (!playable).then(|| match &info {
Ok(i) => {
let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect();
format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / "))
}
Err(e) => format!("{name} was inserted, but it can't be played ({e})"),
});
let decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable));
let warning = if !playable {
Some(match &info {
Ok(i) => {
let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect();
format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / "))
}
Err(e) => format!("{name} was inserted, but it can't be played ({e})"),
})
} else if video && !decodable_video {
// A video codec without a decoder (VP8, say): the clip's audio still plays, but no frame
// can ever show — say so instead of leaving an unexplained black box (#1237).
info.as_ref()
.ok()
.and_then(|i| i.video.as_ref())
.map(|v| format!("{name} was inserted, but its video can't be played ({} isn't supported yet); only the audio plays", v.codec))
} else {
None
};
let poster_png = if video && decodable_video { super::media::poster_png(&bytes, 0) } else { None };
let size = s.doc()?.doc.slide_size;
let rect = rect_param(p, "rect").unwrap_or_else(|| {
+1 -1
View File
@@ -4,7 +4,7 @@ use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{Shape, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, str_param, usize_param, xfrm_of};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+18 -2
View File
@@ -5,7 +5,7 @@
use deckcraft_model::{MediaClip, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_param, cmd, edit_shapes, has_doc, targets};
use crate::{EngineError, MediaStatus, Result, Session, UiRequest};
pub fn specs() -> Vec<CommandSpec> {
@@ -179,7 +179,11 @@ fn gif_play(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_model::ShapeId;
use serde_json::json;
use crate::cmd::base64_encode;
use crate::{MediaStatus, Session, UiRequest};
fn wav() -> Vec<u8> {
let rate = 8000u32;
@@ -286,4 +290,16 @@ mod tests {
assert_eq!(r["playable"], false);
assert!(r["warning"].as_str().is_some_and(|w| w.contains("can't be played")), "{r}");
}
#[test]
fn a_vp8_webm_is_playable_and_warns_that_only_the_audio_plays() {
let mut s = Session::with_new();
let _ = s.execute("slide.new", &json!({"layout": "blank"}));
let clip = include_bytes!("../../../media/tests/fixtures/clip-vp8.webm");
let r = s.execute("insert.video", &json!({"name": "clip.webm", "data": base64_encode(clip)})).expect("insert");
assert_eq!(r["playable"], true, "the Vorbis track plays: {r}");
let w = r["warning"].as_str().expect("the VP8 track can't be shown");
assert!(w.contains("VP8") && w.contains("only the audio plays"), "{w}");
assert!(matches!(s.ui_requests.last(), Some(UiRequest::Message { .. })), "the warning reaches the status bar");
}
}
+7 -2
View File
@@ -9,7 +9,7 @@ use i_overlay::core::overlay_rule::OverlayRule;
use i_overlay::float::single::SingleFloatOverlay;
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, cmd, has_two, str_param, targets, xfrm_of};
use crate::{Result, Session};
/// Closed polygons (slide points) describing a filled area (non-zero winding).
@@ -255,7 +255,12 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_model::{ShapeId, ShapeKind};
use serde_json::json;
use super::{Area, area_of, area_size, letters_of, merge_areas};
use crate::Session;
use crate::cmd::base64_encode;
fn square(x: f64, y: f64, s: f64) -> Area {
vec![vec![[x, y], [x + s, y], [x + s, y + s], [x, y + s]]]
+9
View File
@@ -355,6 +355,11 @@ pub(crate) fn with_param(p: &Value, key: &str, v: Value) -> Value {
/// Targets: `ids` / `id` params, else the selection.
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<ShapeId>> {
if let Some(a) = p.get("ids").and_then(Value::as_array)
&& let Some(v) = a.iter().find(|v| v.as_u64().and_then(|v| u32::try_from(v).ok()).is_none())
{
return Err(bad("shape", format!("`ids` has an invalid shape id: {v}")));
}
if let Some(ids) = ids_param(p, "ids")
&& !ids.is_empty()
{
@@ -510,6 +515,10 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c
}
}
pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> std::collections::HashSet<String> {
current_shapes(doc, sel).map(|v| v.iter().map(|s| s.name.clone()).collect()).unwrap_or_default()
}
/// The command palette runs a command with `{}`. True when the params doc names a value that
/// call would have to supply (`path`, `rows`, `path? | data`), so the palette must not offer it.
/// A name ending in `?` is optional. Text after `→` is the result, not an input.
+1 -1
View File
@@ -6,7 +6,7 @@ use deckcraft_model::style::{Effects, Reflection, Shadow};
use deckcraft_model::{ColorRef, Geom, Line, Shape};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, always, bad, cmd, edit_shapes, has_selection, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -10,7 +10,7 @@ use deckcraft_geom::{PathEl, Point, Xfrm};
use deckcraft_model::{CustomPath, Geom, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, cmd, f64_param, has_selection, id_param, ok, str_param, xfrm_of};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -6,7 +6,7 @@ use deckcraft_model::style::{PictureAdjust, PictureMode};
use deckcraft_model::{ColorRef, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, always, bad, bool_or, cmd, current_shapes, edit_shapes, has_selection, str_param, targets, text, xfrm_of};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -13,7 +13,7 @@ use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
use crate::{Result, Session, media_refs};
pub fn specs() -> Vec<CommandSpec> {
+19 -2
View File
@@ -5,7 +5,9 @@ use std::sync::Arc;
use deckcraft_model::Comment;
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, f64_or, has_doc, has_slide, has_text, id_param, ok, str_param, usize_param,
};
use crate::{Result, Session, Target, UiRequest};
pub fn specs() -> Vec<CommandSpec> {
@@ -430,7 +432,7 @@ fn spelling_change(s: &mut Session, p: &Value) -> Result<Value> {
// One exact occurrence (a squiggled word): paragraph `para`, from character `start`.
if let (Some(id), Some(para), Some(start)) = (shape, usize_param(p, "para"), usize_param(p, "start")) {
let id = deckcraft_model::ShapeId(u32::try_from(id).map_err(|_| bad("review.spellingChange", "bad `shape`"))?);
let end = start + word.chars().count();
let end = start.checked_add(word.chars().count()).ok_or_else(|| bad("review.spellingChange", "bad `start`"))?;
let cell = usize_param(p, "row").zip(usize_param(p, "col"));
let slide = {
let st = s.doc()?;
@@ -843,3 +845,18 @@ mod tests {
assert!(s.active().unwrap().doc.slides[0].comments.is_empty());
}
}
#[cfg(test)]
mod spelling_change_range_tests {
use crate::{EngineError, Session};
use serde_json::json;
#[test]
fn spelling_change_huge_start_is_bad_params() {
let mut s = Session::with_new();
let r = s.execute("insert.textBox", &json!({"text": "Hello"})).unwrap();
let id = r["id"].clone();
let err = s.execute("review.spellingChange", &json!({"shape": id, "para": 0, "start": u64::MAX, "word": "Hello", "to": "x"})).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
}
}
+38 -3
View File
@@ -231,7 +231,11 @@ fn weight_bold(style: &str) -> Option<bool> {
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
let lower = tag.to_ascii_lowercase();
let at = lower.find(key)?;
let at = lower.match_indices(key).find_map(|(at, _)| {
let named = lower[..at].ends_with(|c: char| c.is_ascii_whitespace());
let eq = tag.get(at + key.len()..)?.trim_start().starts_with('=');
(named && eq).then_some(at)
})?;
let after = tag.get(at + key.len()..)?.trim_start();
let after = after.strip_prefix('=')?.trim_start();
let quote = after.chars().next()?;
@@ -304,7 +308,10 @@ fn decode_text(raw: &str) -> String {
while let Some(at) = rest.find('&') {
out.push_str(&collapse_space(&rest[..at]));
rest = &rest[at..];
let search_window = rest.len().min(32);
let mut search_window = rest.len().min(32);
while !rest.is_char_boundary(search_window) {
search_window -= 1;
}
let Some(end) = rest[..search_window].find(';') else {
out.push('&');
rest = &rest[1..];
@@ -343,7 +350,7 @@ fn numeric_char(digits: &str) -> Option<char> {
return None;
}
let n = u32::from_str_radix(body, radix).ok()?;
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
char::from_u32(n).filter(|c| !c.is_control() || matches!(*c, '\n' | '\t'))
}
/// Do the HTML's words and the paste's plain text say the same thing?
@@ -361,6 +368,14 @@ fn fold(s: &str) -> String {
mod tests {
use super::*;
#[test]
fn multibyte_after_ampersand_is_kept() {
let text = "é".repeat(20);
assert_eq!(decode_text(&format!("R&D {text}")), format!("R&D {text}"));
let cjk = "漢".repeat(20);
assert_eq!(decode_text(&format!("&{cjk}")), format!("&{cjk}"));
}
#[test]
fn signed_numeric_entities_are_kept_as_typed() {
assert_eq!(decode_text("a&#+65;b"), "a&#+65;b");
@@ -368,6 +383,12 @@ mod tests {
assert_eq!(decode_text("a&#65;b&#x41;"), "aAbA");
}
#[test]
fn numeric_tab_entity_decodes_to_tab() {
assert_eq!(decode_text("a&#9;b"), "a\tb");
assert_eq!(decode_text("a&#x9;b"), "a\tb");
}
#[test]
fn google_docs_normal_weight_wrapper_is_not_bold() {
let html = "<meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-1\"><span style=\"font-weight:400\">plain words</span></b>";
@@ -382,6 +403,20 @@ mod tests {
assert_eq!(heavy.paragraphs[0].runs[0].props.bold, Some(true));
}
#[test]
fn style_attribute_is_found_after_attributes_containing_style() {
for tag in [
"<span class=\"style1\" style=\"font-weight:bold\">x</span>",
"<span data-style=\"x\" style=\"font-weight:bold\">x</span>",
"<span class=\"mystyle\" style=\"font-weight:bold\">x</span>",
] {
let body = html_to_body(tag).unwrap();
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{tag}");
}
let none = html_to_body("<span class=\"style\">x</span>").unwrap();
assert_eq!(none.paragraphs[0].runs[0].props.bold, None);
}
#[test]
fn bold_italic_and_underline_round_trip() {
let mut body = TextBody::from_text("Hi there");
+1 -1
View File
@@ -5,7 +5,7 @@
use deckcraft_model::{Shape, ShapeId};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, bad, bool_or, cmd, has_slide, id_param, ids_param, ok};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -2,8 +2,8 @@
use serde_json::{Value, json};
use crate::Session;
use crate::tools::{Mods, PointerEvent, PointerKind};
use crate::*;
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
fn deck() -> (Session, Vec<u32>) {
+6 -1
View File
@@ -8,7 +8,10 @@ use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop,
use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, color_param, color_value, edit_geometry, edit_shapes, f64_param, has_selection,
has_slide, id_param, ok, str_param, targets, usize_param, with_param, xfrm_of,
};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -1519,6 +1522,8 @@ fn ink_to_shape(s: &mut Session, _p: &Value) -> Result<Value> {
mod tests {
#![allow(clippy::unwrap_used)]
use deckcraft_model::ShapeId;
use super::*;
use serde_json::json;
+146 -1
View File
@@ -6,7 +6,7 @@ use deckcraft_model::text::TextBody;
use deckcraft_model::{LayoutId, LayoutType, Presentation, Section, SlideId, defaults};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, has_doc, has_slide, ok, opt_usize_param, str_param, usize_param};
use crate::{Result, Session, Target};
pub fn specs() -> Vec<CommandSpec> {
@@ -28,6 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "slide.previous", "Previous Slide", [], Some("PageUp"), "{}", has_slide, prev),
cmd!(noundo "slide.first", "First Slide", [], Some("Home"), "{}", has_slide, first),
cmd!(noundo "slide.last", "Last Slide", [], Some("End"), "{}", has_slide, last),
cmd!(noundo "slide.selectRange", "Select Slide Range", [], None, "{to: index, from?: index (default the current slide), add?: bool (keep the slides already selected)}", has_slide, select_range),
cmd!(noundo "slide.selectSlides", "Select Slides", [], None, "{ids?: [slide id], indices?: [index], add?: bool}", has_slide, select_slides),
cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide),
cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout),
@@ -303,6 +304,7 @@ fn go_to(s: &mut Session, i: usize) -> Result<Value> {
sel.shapes.clear();
sel.text = None;
sel.cells = None;
sel.range_anchor = None;
sel.target = Target::Slides;
})?;
Ok(json!({"index": s.doc()?.selection.slide}))
@@ -368,6 +370,47 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
}
sel.shapes.clear();
sel.text = None;
sel.range_anchor = None;
})?;
Ok(json!({"selected": s.doc()?.selection.slides}))
}
/// Shift-click in the slide list: select every slide from `from` to `to`. Without `from` the range
/// starts at the anchor of the previous Shift-click (so a second Shift-click extends from the same
/// slide, as in PowerPoint), else at the current slide.
/// With `add` the slides already selected stay selected (Ctrl+Shift-click).
fn select_range(s: &mut Session, p: &Value) -> Result<Value> {
let to = usize_param(p, "to").ok_or_else(|| bad("slide.selectRange", "missing `to`"))?;
let given = opt_usize_param(p, "from", "slide.selectRange")?;
let add = bool_or(p, "add", false);
let (from, count) = {
let st = s.doc()?;
let sel = &st.selection;
let current = st.doc.slides.get(sel.slide).map(|x| x.id);
let anchor = sel.range_anchor.filter(|(_, end)| Some(*end) == current).and_then(|(a, _)| st.doc.slide_index(a));
(given.or(anchor).unwrap_or(sel.slide), st.doc.slides.len())
};
if to >= count || from >= count {
return Err(bad("slide.selectRange", "no such slide"));
}
let (a, b) = (from.min(to), from.max(to));
s.select(|doc, sel| {
let range: Vec<SlideId> = doc.slides.iter().skip(a).take(b - a + 1).map(|x| x.id).collect();
if add {
for id in range {
if !sel.slides.contains(&id) {
sel.slides.push(id);
}
}
} else {
sel.slides = range;
}
sel.slide = to;
sel.range_anchor = doc.slides.get(from).zip(doc.slides.get(to)).map(|(a, b)| (a.id, b.id));
sel.shapes.clear();
sel.text = None;
sel.cells = None;
sel.target = Target::Slides;
})?;
Ok(json!({"selected": s.doc()?.selection.slides}))
}
@@ -697,4 +740,106 @@ mod tests {
let want = [(0, "Level one"), (1, "Level two"), (2, "Level three"), (0, "Level one again")];
assert_eq!(got, want.iter().map(|(l, t)| (*l, t.to_string())).collect::<Vec<_>>());
}
fn deck_of(n: usize) -> Session {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < n {
s.execute("slide.new", &json!({})).expect("new slide");
}
s
}
fn selected_indices(s: &Session) -> Vec<usize> {
let st = s.doc().expect("doc");
let mut v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();
v.sort_unstable();
v
}
#[test]
fn select_range_covers_every_slide_between() {
let mut s = deck_of(6);
s.execute("slide.go", &json!({"index": 1})).expect("go");
s.execute("slide.selectRange", &json!({"to": 4})).expect("range");
assert_eq!(selected_indices(&s), vec![1, 2, 3, 4]);
assert_eq!(s.doc().expect("doc").selection.slide, 4);
// Backwards works too, and replaces the earlier selection.
s.execute("slide.selectRange", &json!({"from": 5, "to": 3})).expect("range");
assert_eq!(selected_indices(&s), vec![3, 4, 5]);
}
#[test]
fn select_range_add_keeps_the_existing_selection() {
let mut s = deck_of(8);
s.execute("slide.selectSlides", &json!({"indices": [0, 1]})).expect("select");
s.execute("slide.go", &json!({"index": 4})).expect("go");
s.execute("slide.selectSlides", &json!({"indices": [4]})).expect("select");
s.execute("slide.selectRange", &json!({"to": 6, "add": true})).expect("range");
assert_eq!(selected_indices(&s), vec![4, 5, 6]);
s.execute("slide.selectSlides", &json!({"indices": [0]})).expect("select");
s.execute("slide.selectRange", &json!({"from": 3, "to": 5, "add": true})).expect("range");
assert_eq!(selected_indices(&s), vec![0, 3, 4, 5]);
}
#[test]
fn select_range_rejects_out_of_range_slides() {
let mut s = deck_of(3);
assert!(s.execute("slide.selectRange", &json!({"to": 3})).is_err());
assert!(s.execute("slide.selectRange", &json!({"from": 9, "to": 1})).is_err());
assert!(s.execute("slide.selectRange", &json!({})).is_err());
}
#[test]
fn select_range_rejects_an_invalid_from_instead_of_using_the_current_slide() {
let mut s = deck_of(4);
s.execute("slide.go", &json!({"index": 2})).expect("go");
assert!(s.execute("slide.selectRange", &json!({"from": -1, "to": 3})).is_err());
assert!(s.execute("slide.selectRange", &json!({"from": "x", "to": 3})).is_err());
assert_eq!(selected_indices(&s), vec![2]);
}
#[test]
fn a_second_range_extends_from_the_original_anchor() {
let mut s = deck_of(8);
s.execute("slide.go", &json!({"index": 2})).expect("go");
s.execute("slide.selectRange", &json!({"to": 5})).expect("range");
assert_eq!(selected_indices(&s), vec![2, 3, 4, 5]);
// Shift-click further on: still anchored at 2, not at 5.
s.execute("slide.selectRange", &json!({"to": 7})).expect("range");
assert_eq!(selected_indices(&s), vec![2, 3, 4, 5, 6, 7]);
// Shift-click back across the anchor.
s.execute("slide.selectRange", &json!({"to": 0})).expect("range");
assert_eq!(selected_indices(&s), vec![0, 1, 2]);
// A plain click on another slide starts a new anchor.
s.execute("slide.go", &json!({"index": 4})).expect("go");
s.execute("slide.selectRange", &json!({"to": 6})).expect("range");
assert_eq!(selected_indices(&s), vec![4, 5, 6]);
}
#[test]
fn moving_a_slide_drops_the_range_anchor() {
let mut s = deck_of(6);
s.execute("slide.go", &json!({"index": 0})).expect("go");
s.execute("slide.selectRange", &json!({"to": 2})).expect("range");
// Slide 0 moves to index 2 and stays current; index 0 now holds another slide, so the
// next Shift-click starts at the moved slide instead of at index 0.
s.execute("slide.move", &json!({"from": 0, "to": 2})).expect("move");
assert_eq!(s.doc().expect("doc").selection.slide, 2);
s.execute("slide.selectRange", &json!({"to": 4})).expect("range");
assert_eq!(selected_indices(&s), vec![2, 3, 4]);
}
#[test]
fn select_range_clears_a_table_cell_selection_and_returns_to_the_slides() {
let mut s = deck_of(3);
s.select(|_, sel| {
sel.cells = Some((deckcraft_model::ShapeId(1), (0, 0), (1, 1)));
sel.target = crate::Target::Master { master: 0 };
})
.expect("cells");
s.execute("slide.selectRange", &json!({"to": 2})).expect("range");
let sel = &s.doc().expect("doc").selection;
assert!(sel.cells.is_none());
assert_eq!(sel.target, crate::Target::Slides);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ use deckcraft_model::{
};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, current_shapes, has_selection, shape_of, str_param, targets, xfrm_of};
use crate::{EngineError, Result, Session};
/// Deepest outline level the Text Pane allows.
+58 -24
View File
@@ -4,28 +4,49 @@ use deckcraft_geom::{Affine, Point, Rect};
use deckcraft_model::{Shape, ShapeKind, Table};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, bad, bool_or, bool_param, cmd, color_param, edit_shapes, f64_param, has_selection, has_slide, id_param, ok, opt_usize_param,
str_param, table_drawn_heights, xfrm_of,
};
use crate::{DocState, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("table.insertRowAbove", "Insert Above", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols(
s, p, "rowAbove"
s,
p,
"rowAbove",
"table.insertRowAbove"
)),
cmd!("table.insertRowBelow", "Insert Below", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols(
s, p, "rowBelow"
s,
p,
"rowBelow",
"table.insertRowBelow"
)),
cmd!("table.insertColumnLeft", "Insert Left", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols(
s, p, "colLeft"
s,
p,
"colLeft",
"table.insertColumnLeft"
)),
cmd!("table.insertColumnRight", "Insert Right", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols(
s, p, "colRight"
s,
p,
"colRight",
"table.insertColumnRight"
)),
cmd!("table.deleteRow", "Delete Rows", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols(
s, p, "delRow"
s,
p,
"delRow",
"table.deleteRow"
)),
cmd!("table.deleteColumn", "Delete Columns", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols(
s, p, "delCol"
s,
p,
"delCol",
"table.deleteColumn"
)),
cmd!("table.merge", "Merge Cells", ["Table Layout", "Merge"], None, "{from: [r, c], to: [r, c], id?}", has_selection, merge),
cmd!(
@@ -225,16 +246,26 @@ impl TableFrame {
}
}
fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> {
let a = p.get(key)?.as_array()?;
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
/// A `[r, c]` cell param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
/// present but not a valid cell, so a bad cell never acts on the selection.
fn cell_param(p: &Value, key: &str, cmd: &str) -> Result<Option<(usize, usize)>> {
let v = match p.get(key) {
None | Some(Value::Null) => return Ok(None),
Some(v) => v,
};
let idx = |i: usize| v.as_array().and_then(|a| a.get(i)).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
match (idx(0), idx(1)) {
(Some(r), Some(c)) => Ok(Some((r, c))),
_ => Err(bad(cmd, format!("`{key}` is not a valid cell: {v}"))),
}
}
/// The table's row/col to act on: params, the edited cell, or the selected cells.
fn at(s: &Session, p: &Value) -> (usize, usize) {
/// A present but invalid `row`/`col` is an error; only an absent one falls back.
fn at(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> {
let st = s.active();
let cell = st.and_then(|d| d.selection.text.as_ref().and_then(|t| t.cell)).or_else(|| st.and_then(|d| d.selection.cells.map(|c| c.1)));
(usize_param(p, "row").or(cell.map(|c| c.0)).unwrap_or(0), usize_param(p, "col").or(cell.map(|c| c.1)).unwrap_or(0))
Ok((opt_usize_param(p, "row", cmd)?.or(cell.map(|c| c.0)).unwrap_or(0), opt_usize_param(p, "col", cmd)?.or(cell.map(|c| c.1)).unwrap_or(0)))
}
fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> Result<()>) -> Result<Value> {
@@ -282,10 +313,10 @@ fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> R
Ok(r)
}
fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
let (r, c) = at(s, p);
fn rows_cols(s: &mut Session, p: &Value, op: &str, name: &'static str) -> Result<Value> {
let (r, c) = at(s, p, name)?;
let op = op.to_string();
with_table(s, p, "table", move |t| {
with_table(s, p, name, move |t| {
match op.as_str() {
"rowAbove" => t.insert_row(r),
"rowBelow" => t.insert_row(r.saturating_add(1)),
@@ -293,12 +324,12 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
"colRight" => t.insert_col(c.saturating_add(1)),
"delRow" => {
if !t.delete_row(r) {
return Err(bad("table.deleteRow", "can't delete the last row"));
return Err(bad(name, "can't delete the last row"));
}
}
_ => {
if !t.delete_col(c) {
return Err(bad("table.deleteColumn", "can't delete the last column"));
return Err(bad(name, "can't delete the last column"));
}
}
}
@@ -308,8 +339,8 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
let sel = s.doc()?.selection.cells;
let from = cell_param(p, "from").or(sel.map(|c| c.1)).ok_or_else(|| bad("table.merge", "missing `from`"))?;
let to = cell_param(p, "to").or(sel.map(|c| c.2)).ok_or_else(|| bad("table.merge", "missing `to`"))?;
let from = cell_param(p, "from", "table.merge")?.or(sel.map(|c| c.1)).ok_or_else(|| bad("table.merge", "missing `from`"))?;
let to = cell_param(p, "to", "table.merge")?.or(sel.map(|c| c.2)).ok_or_else(|| bad("table.merge", "missing `to`"))?;
with_table(
s,
p,
@@ -319,7 +350,10 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
}
fn split(s: &mut Session, p: &Value) -> Result<Value> {
let cell = cell_param(p, "cell").unwrap_or_else(|| at(s, p));
let cell = match cell_param(p, "cell", "table.split")? {
Some(c) => c,
None => at(s, p, "table.split")?,
};
with_table(s, p, "table.split", move |t| if t.split(cell.0, cell.1) { Ok(()) } else { Err(bad("table.split", "that cell isn't merged")) })
}
@@ -533,7 +567,7 @@ fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result<Value> {
}
fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
let (_, c) = at(s, p);
let (_, c) = at(s, p, "table.columnWidth")?;
let w = f64_param(p, "width").ok_or_else(|| bad("table.columnWidth", "missing `width`"))?.clamp(4.0, 4000.0);
let keep_total = bool_or(p, "keepTotal", false);
with_table(s, p, "table.columnWidth", move |t| {
@@ -562,7 +596,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
}
fn row_height(s: &mut Session, p: &Value) -> Result<Value> {
let (r, _) = at(s, p);
let (r, _) = at(s, p, "table.rowHeight")?;
let h = f64_param(p, "height").ok_or_else(|| bad("table.rowHeight", "missing `height`"))?.clamp(4.0, 4000.0);
with_table(s, p, "table.rowHeight", move |t| {
if let Some(x) = t.rows.get_mut(r) {
@@ -577,8 +611,8 @@ fn select_cells(s: &mut Session, p: &Value) -> Result<Value> {
Some(i) => i,
None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("table.selectCells", "select a table"))?,
};
let from = cell_param(p, "from").unwrap_or((0, 0));
let to = cell_param(p, "to").unwrap_or(from);
let from = cell_param(p, "from", "table.selectCells")?.unwrap_or((0, 0));
let to = cell_param(p, "to", "table.selectCells")?.unwrap_or(from);
s.select(|_, sel| {
sel.cells = Some((id, from, to));
sel.shapes = vec![id];
+4 -1
View File
@@ -9,7 +9,10 @@ use deckcraft_model::text::{Align, AutoFit, Hyperlink, Paragraph, Run, RunKind,
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use super::{
CommandSpec, NO_TEXT_POINT, SELECT_TEXT_OR_SHAPE, always, bad, bool_or, cmd, edit_shapes, has_doc, has_slide, has_text, id_param, ok, shape_of,
str_param, xfrm_of,
};
use crate::{DocState, HistoryEntry, Result, Selection, Session, Target, TextSel, push_undo};
/// Catalogue text-editing, selection, autocorrect, font, IME, and surrounding-deletion commands
+6 -2
View File
@@ -6,7 +6,7 @@ use deckcraft_model::Transition;
use deckcraft_model::anim::TRANSITIONS;
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, str_param, usize_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -111,7 +111,11 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
}
match p.get("after") {
Some(Value::Null) => t.advance_after_ms = None,
Some(v) => t.advance_after_ms = v.as_u64().map(|x| x.min(86_400_000) as u32),
Some(v) => {
if let Some(x) = v.as_u64() {
t.advance_after_ms = Some(x.min(86_400_000) as u32);
}
}
None => {}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ use deckcraft_model::text::{Action, Hyperlink, TextBody};
use deckcraft_model::{LayoutType, Shape, ShapeId, ShapeKind, SlideId, defaults};
use serde_json::{Value, json};
use super::*;
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_slide, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
+9
View File
@@ -155,6 +155,11 @@ pub struct Selection {
pub text: Option<TextSel>,
/// Selected table cells: (table, (r0, c0), (r1, c1)).
pub cells: Option<(ShapeId, (usize, usize), (usize, usize))>,
/// Slide-list range last made with Shift-click: `(anchor, end)`. A further Shift-click extends from
/// the anchor while the current slide is still `end`; moving to another slide drops it. Held as
/// slide ids, so moving, inserting or deleting slides can't make it name other slides.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub range_anchor: Option<(SlideId, SlideId)>,
pub target: Target,
}
@@ -353,6 +358,9 @@ pub struct Prefs {
pub undo_limit: usize,
/// Minutes between AutoRecover saves of unsaved presentations.
pub recovery_minutes: f64,
/// The unit the rulers, the Format pane and the size dialogs show (Preferences); the deck
/// itself always stores points.
pub units: deckcraft_geom::Unit,
}
impl Default for Prefs {
@@ -376,6 +384,7 @@ impl Default for Prefs {
author: "Presenter".into(),
undo_limit: 500,
recovery_minutes: 1.0,
units: deckcraft_geom::Unit::Inches,
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ use std::collections::VecDeque;
use serde_json::{Value, json};
use crate::*;
use crate::{EngineError, Session, command_specs, sample};
/// Commands that touch the file system, other windows or real time; the soak drives the rest.
fn skipped(id: &str) -> bool {
+10
View File
@@ -357,6 +357,9 @@ impl Dictionary {
/// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass.
pub fn check(&self, word: &str) -> bool {
if word.contains('\u{2019}') {
return self.check(&word.replace('\u{2019}', "'"));
}
if word.is_empty() || word.chars().all(|c| !c.is_alphabetic()) {
return true;
}
@@ -800,6 +803,13 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
Dictionary::parse(AFF.as_bytes(), DIC.as_bytes())
}
#[test]
fn curly_apostrophe_matches_ascii_entry() {
let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes());
assert!(d.check("don't"));
assert!(d.check("don\u{2019}t"));
}
#[test]
fn roots_affixes_and_cross_products_check() {
let d = d();
+208 -1
View File
@@ -1,6 +1,10 @@
use serde_json::json;
use crate::*;
use crate::tools::{Mods, PointerEvent, PointerKind};
use crate::{
DocState, History, Presentation, Session, Target, TextSel, ToolKind, Value, cmd, command_specs, connect, find_command, model, sample, tools,
};
use deckcraft_model::ShapeId;
fn session() -> Session {
Session::with_new()
@@ -210,6 +214,77 @@ fn clipboard_copy_paste_shapes_and_slides() {
assert!(s.doc().unwrap().doc.validate().is_empty());
}
#[test]
fn pasted_shapes_get_the_next_automatic_name() {
let mut s = session();
let names = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
let before = names(&s).len();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();
let auto = names(&s)[before].clone();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let all = names(&s);
let base = auto.rsplit_once(' ').unwrap().0;
assert_eq!(all[before + 1], format!("{base} {}", before + 2));
assert_eq!(all[before + 2], format!("{base} {}", before + 3));
// A name the user chose is not an automatic one and stays on the copy.
let id = s.doc().unwrap().selection.shapes[0].0;
s.execute("shape.rename", &json!({"id": id, "name": "Hero"})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(names(&s).last().unwrap(), "Hero");
}
#[test]
fn pasting_after_a_deleted_shape_does_not_repeat_a_name() {
let mut s = session();
let names = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
let mut ids = vec![];
for _ in 0..3 {
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();
ids.push(s.doc().unwrap().selection.shapes[0].0);
}
s.execute("edit.delete", &json!({"ids": [ids[1]]})).unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
s.doc_mut().unwrap().selection.shapes = vec![deckcraft_model::ShapeId(ids[2])];
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let all = names(&s);
let mut unique = all.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
#[test]
fn pasting_on_another_slide_keeps_names_so_morph_still_pairs_them() {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 20, 20]})).unwrap();
let first = s.doc().unwrap().selection.shapes[0].0;
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 200, 80, 80]})).unwrap();
s.execute("edit.delete", &json!({"ids": [first]})).unwrap();
s.execute("edit.selectAll", &json!({})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let st = s.doc().unwrap();
let (a, b) = (&st.doc.slides[1], &st.doc.slides[2]);
let an: Vec<_> = a.shapes.iter().map(|x| x.name.clone()).collect();
let bn: Vec<_> = b.shapes.iter().map(|x| x.name.clone()).collect();
assert_eq!(an, bn);
let pairs = deckcraft_anim::morph_pairs(a, b);
assert_eq!(pairs.len(), 2);
for (ia, ib) in pairs {
let na = &a.shapes.iter().find(|x| x.id == ia).unwrap().name;
let nb = &b.shapes.iter().find(|x| x.id == ib).unwrap().name;
assert_eq!(na, nb);
}
}
#[test]
fn bold_copy_is_html_and_pages_html_pastes_bold() {
let mut s = session();
@@ -4471,6 +4546,64 @@ fn language_marks_text_and_no_proof_and_repeats_are_reported() {
assert!(s.execute("format.language", &json!({"lang": "en US!"})).is_err());
}
/// Review › Language › Whole presentation: every run, placeholder list style and default text style
/// takes the language, with nothing selected, in one undo step.
#[test]
fn language_all_marks_every_text_in_the_deck_with_nothing_selected() {
crate::spell::install("xx-AL", crate::spell::Dictionary::parse(b"", b"2\nhello\nworld\n"));
let mut s = session();
let a =
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "hello wurld"})).unwrap()["id"].as_u64().unwrap();
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
s.execute("format.language", &json!({"lang": "de-DE"})).unwrap();
s.execute("slide.new", &json!({"title": "world hellow"})).unwrap();
s.execute("insert.table", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("edit.select", &json!({"ids": []})).unwrap();
// A layout placeholder with its own list style (a:lstStyle) in another language.
let placeholder = s
.edit(|doc, _| {
let m = std::sync::Arc::make_mut(doc.masters.first_mut().unwrap());
let sh = m.layouts[0].shapes.iter_mut().find(|sh| sh.text.is_some()).unwrap();
let lv = deckcraft_model::text::LevelStyle {
run: deckcraft_model::RunProps { lang: Some("de-DE".into()), ..Default::default() },
..Default::default()
};
sh.text.as_mut().unwrap().list_style.set(0, lv);
Ok(sh.id)
})
.unwrap();
s.execute("format.languageAll", &json!({"lang": "xx-AL"})).unwrap();
let st = s.doc().unwrap();
let ph = deckcraft_model::find_shape(&st.doc.masters[0].layouts[0].shapes, placeholder).unwrap();
assert_eq!(ph.text.as_ref().unwrap().list_style.level(0).unwrap().run.lang.as_deref(), Some("xx-AL"));
// A body without a list style doesn't get one.
let boxed = st.doc.slides.iter().find_map(|sl| deckcraft_model::find_shape(&sl.shapes, deckcraft_model::ShapeId(a as u32))).unwrap();
assert!(boxed.text.as_ref().unwrap().list_style.is_empty());
let mut langs = vec![];
for sl in &st.doc.slides {
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
langs.extend(sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs).map(|r| r.props.lang.clone()));
if let deckcraft_model::ShapeKind::Table(t) = &sh.kind {
langs.extend(t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.text.paragraphs).map(|p| p.end_props.lang.clone()));
}
});
}
assert!(!langs.is_empty() && langs.iter().all(|l| l.as_deref() == Some("xx-AL")), "{langs:?}");
// The defaults new text inherits.
assert_eq!(st.doc.default_text_style.level(0).unwrap().run.lang.as_deref(), Some("xx-AL"));
assert!(st.doc.masters.iter().all(|m| m.body_style.level(0).is_some_and(|l| l.run.lang.as_deref() == Some("xx-AL"))));
let words: Vec<String> =
s.execute("review.spelling", &json!({})).unwrap().as_array().unwrap().iter().map(|w| w["word"].as_str().unwrap().to_string()).collect();
assert_eq!(words, vec!["wurld", "hellow"]);
// One undo step puts the per-box language back.
s.execute("edit.undo", &json!({})).unwrap();
let st = s.doc().unwrap();
let sh = st.doc.slides.iter().find_map(|sl| deckcraft_model::find_shape(&sl.shapes, deckcraft_model::ShapeId(a as u32))).unwrap();
assert_eq!(sh.text.as_ref().unwrap().paragraphs[0].runs[0].props.lang.as_deref(), Some("de-DE"));
assert!(s.execute("format.languageAll", &json!({})).is_err());
assert!(s.execute("format.languageAll", &json!({"lang": "en US!"})).is_err());
}
#[test]
fn the_caret_takes_the_colour_of_the_text() {
let caret = |theme: &str| {
@@ -4879,6 +5012,55 @@ fn paragraph_commands_clamp_a_direct_hostile_text_selection() {
assert_eq!(body.paragraphs[0].props.align, Some(deckcraft_model::text::Align::Center));
}
#[test]
fn table_row_col_commands_reject_an_invalid_index() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"], ["e", "f"]]));
for (cmd, key) in [
("table.deleteRow", "row"),
("table.deleteColumn", "col"),
("table.insertRowAbove", "row"),
("table.insertColumnLeft", "col"),
("table.split", "row"),
] {
for bad in [json!(-1), json!(1.5), json!("1")] {
let r = s.execute(cmd, &json!({"id": id.0, key: bad}));
assert!(r.is_err(), "{cmd} {key}={bad} should be rejected, got {r:?}");
let t = table_of(&s, id);
assert_eq!((t.n_rows(), t.n_cols()), (3, 2), "{cmd} {key}={bad} must leave the table unchanged");
}
}
}
#[test]
fn table_cell_commands_reject_an_invalid_cell_and_name_the_registered_command() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"], ["e", "f"]]));
s.execute("table.selectCells", &json!({"id": id.0, "from": [0, 0], "to": [1, 1]})).unwrap();
for (cmd, params) in [
("table.split", json!({"id": id.0, "cell": [-1, 0]})),
("table.merge", json!({"id": id.0, "from": [-1, 0], "to": [1, 1]})),
("table.merge", json!({"id": id.0, "from": [0, 0], "to": "x"})),
("table.rowHeight", json!({"id": id.0, "row": -1, "height": 50.0})),
("table.rowHeight", json!({"id": id.0, "row": 1.5, "height": 50.0})),
("table.columnWidth", json!({"id": id.0, "col": -1, "width": 50.0})),
("table.columnWidth", json!({"id": id.0, "col": "1", "width": 50.0, "keepTotal": true})),
] {
let before = table_of(&s, id);
let (rows, cols) = (before.rows.iter().map(|r| r.height).collect::<Vec<_>>(), before.cols.clone());
let r = s.execute(cmd, &params);
assert!(r.is_err(), "{cmd} {params} should be rejected, got {r:?}");
let t = table_of(&s, id);
assert_eq!((t.n_rows(), t.n_cols()), (3, 2), "{cmd} {params} must leave the table unchanged");
assert_eq!(t.rows.iter().map(|r| r.height).collect::<Vec<_>>(), rows, "{cmd} {params} must not change row heights");
assert_eq!(t.cols, cols, "{cmd} {params} must not change column widths");
}
let e = s.execute("table.deleteRow", &json!({"id": id.0, "row": -1})).unwrap_err().to_string();
assert!(e.contains("table.deleteRow"), "error should name the registered command: {e}");
let e = s.execute("table.insertColumnLeft", &json!({"id": id.0, "col": -1})).unwrap_err().to_string();
assert!(e.contains("table.insertColumnLeft"), "error should name the registered command: {e}");
}
#[test]
fn table_column_width_of_a_huge_column_is_ignored() {
let mut s = session();
@@ -5439,3 +5621,28 @@ fn slide_hide_and_layout_reject_invalid_index() {
}
assert!(s.doc().unwrap().doc.slides.iter().all(|x| !x.hidden));
}
#[test]
fn transition_timing_invalid_after_keeps_value() {
let mut s = session();
let after = |s: &Session| s.doc().unwrap().doc.slides[0].transition.as_ref().and_then(|t| t.advance_after_ms);
s.execute("transition.timing", &json!({"after": 5000})).unwrap();
assert_eq!(after(&s), Some(5000));
for bad in [json!(-5), json!(1.5), json!("abc")] {
let _ = s.execute("transition.timing", &json!({"after": bad}));
assert_eq!(after(&s), Some(5000), "after:{bad} must not clear the auto-advance");
}
s.execute("transition.timing", &json!({"after": null})).unwrap();
assert_eq!(after(&s), None);
}
#[test]
fn invalid_ids_array_never_falls_back_to_the_selection() {
let mut s = session();
let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap();
for ids in [json!([-1]), json!([1.5]), json!([4294967296u64]), json!([-1, a])] {
let r = s.execute("edit.delete", &json!({ "ids": ids }));
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "invalid ids {ids} gave {r:?}");
assert!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a as u32)).is_some(), "shape deleted for {ids}");
}
}
+5 -2
View File
@@ -1,6 +1,9 @@
use super::*;
use std::io::{Cursor, Write};
use std::sync::Arc;
use super::{Budget, Presentation, load, load_checked, load_within, read_entry, read_entry_max, save};
use deckcraft_model::chart::{Chart, ChartPart, ChartRel, ChartType};
use deckcraft_model::{Master, Shape, ShapeId};
use deckcraft_model::{Master, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
const PART: &str = "ppt/embeddings/owned.xlsx";
+251 -142
View File
@@ -57,9 +57,17 @@ pub fn slides_to_rtf(p: &Presentation) -> String {
for s in &p.slides {
let title = s.shapes.iter().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_ref());
out.push_str("\\pard\\outlinelevel0\\sb240\\b\\fs32 ");
match title.and_then(|t| t.paragraphs.iter().find(|p| !p.is_empty())) {
Some(tp) => runs(&mut out, tp),
None => escape(&mut out, &s.title()),
// The title stays one outline paragraph (a `\par` would start another slide on re-import),
// so its paragraphs are joined with line breaks.
let mut tps = title.into_iter().flat_map(|t| &t.paragraphs).filter(|p| !p.is_empty()).peekable();
if tps.peek().is_none() {
escape(&mut out, &s.title());
}
for (k, tp) in tps.enumerate() {
if k > 0 {
out.push_str("\\line ");
}
runs(&mut out, tp);
}
out.push_str("\\b0\\par\n");
for sh in &s.shapes {
@@ -104,6 +112,12 @@ const SKIP: &[&str] = &[
"rsidtbl",
"object",
"fldinst",
// A list item's rendered bullet or number, written before its text (Word: listtext; RTF 1.5 and
// older writers: pntext, pntxta, pntxtb). Its `\pard` must not reset the paragraph's indent.
"listtext",
"pntext",
"pntxta",
"pntxtb",
];
/// `i + n`, or one character on when that would land inside a multi-byte character (a fallback
@@ -112,154 +126,216 @@ fn advance(rtf: &str, i: usize, n: usize) -> usize {
if rtf.is_char_boundary(i + n) { i + n } else { i + rtf.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8) }
}
/// Group state: whether the text is being skipped, and the `\uc` fallback count.
#[derive(Clone, Copy)]
struct St {
skip: bool,
uc: usize,
}
/// Parser state for [`rtf_to_outline`]: the cursor, the group stack and the outline so far.
struct Outline<'a> {
rtf: &'a str,
b: &'a [u8],
i: usize,
stack: Vec<St>,
st: St,
level: Option<u32>,
indent: i64,
pending_skip: usize,
line: String,
out: String,
}
/// Read an RTF outline into tab-indented outline text: a paragraph's outline level (or its left
/// indent in 0.25" steps) becomes its number of tabs.
pub fn rtf_to_outline(rtf: &str) -> String {
#[derive(Clone, Copy)]
struct St {
skip: bool,
uc: usize,
}
let b = rtf.as_bytes();
let mut stack: Vec<St> = vec![];
let mut st = St { skip: false, uc: 1 };
let (mut level, mut indent): (Option<u32>, i64) = (None, 0);
let mut line = String::new();
let mut out = String::new();
let mut pending_skip = 0usize;
let mut i = 0;
let flush = |line: &mut String, level: Option<u32>, indent: i64, out: &mut String| {
let text = line.trim();
if !text.is_empty() {
let n = level.map(|l| l as i64).unwrap_or(indent.saturating_add(180) / 360).clamp(0, 9) as usize;
out.push_str(&"\t".repeat(n));
out.push_str(text);
out.push('\n');
}
line.clear();
let mut p = Outline {
rtf,
b: rtf.as_bytes(),
i: 0,
stack: vec![],
st: St { skip: false, uc: 1 },
level: None,
indent: 0,
pending_skip: 0,
line: String::new(),
out: String::new(),
};
while i < b.len() {
let c = b[i];
match c {
b'{' => {
stack.push(st);
i += 1;
// `{\*\dest …}`: an ignorable destination.
if b[i..].starts_with(b"\\*") {
st.skip = true;
}
}
b'}' => {
st = stack.pop().unwrap_or(St { skip: false, uc: 1 });
i += 1;
}
b'\\' => {
i += 1;
let Some(&n) = b.get(i) else { break };
if n.is_ascii_alphabetic() {
let s = i;
while i < b.len() && b[i].is_ascii_alphabetic() {
i += 1;
}
let word = &rtf[s..i];
let ns = i;
if i < b.len() && (b[i] == b'-' || b[i].is_ascii_digit()) {
i += 1;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
}
let arg: Option<i64> = rtf[ns..i].parse().ok();
if i < b.len() && b[i] == b' ' {
i += 1;
}
if pending_skip > 0 {
pending_skip -= 1;
continue;
}
if SKIP.contains(&word) {
st.skip = true;
}
if st.skip {
continue;
}
match word {
"par" => {
flush(&mut line, level, indent, &mut out);
}
"pard" => {
level = None;
indent = 0;
}
"outlinelevel" => level = arg.map(|a| a.clamp(0, 9) as u32),
"li" => indent = arg.unwrap_or(0),
"tab" | "line" => line.push(' '),
"uc" => st.uc = arg.unwrap_or(1).clamp(0, 8) as usize,
"u" => {
let v = arg.unwrap_or(0);
// RTF writes UTF-16 units as signed 16-bit values.
push_utf16(&mut line, (v & 0xffff) as u32);
pending_skip = 0;
// Skip the fallback characters.
let mut k = st.uc;
while k > 0 && i < b.len() {
if b[i] == b'\\' && b.get(i + 1) == Some(&b'\'') {
i = advance(rtf, i, 4);
} else if b[i] == b'{' || b[i] == b'}' || b[i] == b'\\' {
break;
} else {
i = advance(rtf, i, 1);
}
k -= 1;
}
}
"emdash" => line.push('—'),
"endash" => line.push('–'),
"bullet" => line.push('•'),
"lquote" => line.push('‘'),
"rquote" => line.push('’'),
"ldblquote" => line.push('“'),
"rdblquote" => line.push('”'),
_ => {}
}
} else if n == b'\'' {
let hex = rtf.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok());
i = advance(rtf, i, 3);
if !st.skip
&& let Some(h) = hex
&& h != 0
{
line.push(cp1252(h));
}
} else {
i += 1;
if st.skip {
continue;
}
match n {
b'\\' | b'{' | b'}' => line.push(n as char),
b'~' => line.push('\u{a0}'),
b'\n' | b'\r' => flush(&mut line, level, indent, &mut out),
_ => {}
p.run();
p.flush();
finish_surrogates(p.out)
}
impl Outline<'_> {
fn run(&mut self) {
while self.i < self.b.len() {
match self.b[self.i] {
b'{' => self.open_group(),
b'}' => self.close_group(),
b'\\' => {
self.i += 1;
if !self.escape() {
break;
}
}
}
b'\r' | b'\n' => i += 1,
_ => {
let s = i;
while i < b.len() && !matches!(b[i], b'{' | b'}' | b'\\' | b'\r' | b'\n') {
i += 1;
}
if !st.skip
&& let Some(t) = rtf.get(s..i)
{
line.push_str(&t.replace('\u{0}', ""));
}
b'\r' | b'\n' => self.i += 1,
_ => self.text(),
}
}
}
flush(&mut line, level, indent, &mut out);
finish_surrogates(out)
/// `{`: push the group; `{\*\dest …}` is an ignorable destination.
fn open_group(&mut self) {
self.stack.push(self.st);
self.i += 1;
if self.b[self.i..].starts_with(b"\\*") {
self.st.skip = true;
}
}
fn close_group(&mut self) {
self.st = self.stack.pop().unwrap_or(St { skip: false, uc: 1 });
self.i += 1;
}
/// The character after a backslash: a control word, a hex escape or a symbol. False at the
/// end of the input.
fn escape(&mut self) -> bool {
let Some(&n) = self.b.get(self.i) else { return false };
if n.is_ascii_alphabetic() {
self.control_word();
} else if n == b'\'' {
self.hex_escape();
} else {
self.symbol(n);
}
true
}
fn control_word(&mut self) {
let rtf = self.rtf;
let s = self.i;
while self.i < self.b.len() && self.b[self.i].is_ascii_alphabetic() {
self.i += 1;
}
let word = &rtf[s..self.i];
let ns = self.i;
if self.i < self.b.len() && (self.b[self.i] == b'-' || self.b[self.i].is_ascii_digit()) {
self.i += 1;
while self.i < self.b.len() && self.b[self.i].is_ascii_digit() {
self.i += 1;
}
}
let arg: Option<i64> = rtf[ns..self.i].parse().ok();
if self.i < self.b.len() && self.b[self.i] == b' ' {
self.i += 1;
}
if self.pending_skip > 0 {
self.pending_skip -= 1;
return;
}
if SKIP.contains(&word) {
self.st.skip = true;
}
if !self.st.skip {
self.apply(word, arg);
}
}
fn apply(&mut self, word: &str, arg: Option<i64>) {
match word {
"par" => self.flush(),
"pard" => {
self.level = None;
self.indent = 0;
}
"outlinelevel" => self.level = arg.map(|a| a.clamp(0, 9) as u32),
"li" => self.indent = arg.unwrap_or(0),
"tab" | "line" => self.line.push(' '),
"uc" => self.st.uc = arg.unwrap_or(1).clamp(0, 8) as usize,
"u" => self.unicode(arg.unwrap_or(0)),
"emdash" => self.line.push('—'),
"endash" => self.line.push('–'),
"bullet" => self.line.push('•'),
"lquote" => self.line.push('‘'),
"rquote" => self.line.push('’'),
"ldblquote" => self.line.push('“'),
"rdblquote" => self.line.push('”'),
_ => {}
}
}
/// `\uN`: one UTF-16 unit, then the `\uc` fallback characters, which are skipped.
fn unicode(&mut self, v: i64) {
// RTF writes UTF-16 units as signed 16-bit values.
push_utf16(&mut self.line, (v & 0xffff) as u32);
self.pending_skip = 0;
let mut k = self.st.uc;
while k > 0 && self.i < self.b.len() {
if self.b[self.i] == b'\\' && self.b.get(self.i + 1) == Some(&b'\'') {
self.i = advance(self.rtf, self.i, 4);
} else if matches!(self.b[self.i], b'{' | b'}' | b'\\') {
break;
} else {
self.i = advance(self.rtf, self.i, 1);
}
k -= 1;
}
}
/// `\'hh`: one cp1252 byte.
fn hex_escape(&mut self) {
let hex =
self.rtf.get(self.i + 1..self.i + 3).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|h| u8::from_str_radix(h, 16).ok());
self.i = advance(self.rtf, self.i, 3);
if !self.st.skip
&& let Some(h) = hex
&& h != 0
{
self.line.push(cp1252(h));
}
}
/// A one-character control symbol (`\\`, `\{`, `\~`, ...).
fn symbol(&mut self, n: u8) {
self.i += 1;
if self.st.skip {
return;
}
match n {
b'\\' | b'{' | b'}' => self.line.push(n as char),
b'~' => self.line.push('\u{a0}'),
b'_' => self.line.push('\u{2011}'),
b'\n' | b'\r' => self.flush(),
_ => {}
}
}
/// Plain text up to the next special character.
fn text(&mut self) {
let s = self.i;
while self.i < self.b.len() && !matches!(self.b[self.i], b'{' | b'}' | b'\\' | b'\r' | b'\n') {
self.i += 1;
}
if !self.st.skip
&& let Some(t) = self.rtf.get(s..self.i)
{
self.line.push_str(&t.replace('\u{0}', ""));
}
}
/// Ends the current line: a non-empty line becomes one outline entry, indented by its level.
fn flush(&mut self) {
let text = self.line.trim();
if !text.is_empty() {
let n = self.level.map(|l| l as i64).unwrap_or(self.indent.saturating_add(180) / 360).clamp(0, 9) as usize;
self.out.push_str(&"\t".repeat(n));
self.out.push_str(text);
self.out.push('\n');
}
self.line.clear();
}
}
/// Push a UTF-16 unit; surrogate halves are parked as private markers (NUL plus four hex digits)
@@ -318,6 +394,12 @@ mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
/// A sign in a `\'hh` escape is not a hex digit and must be ignored.
#[test]
fn hex_escape_rejects_sign() {
assert_eq!(rtf_to_outline(r"{\rtf1 a\'+5b\par}"), "ab\n");
}
/// Non-ASCII text after a `\\uN` or `\\'hh` escape used to split a character and panic.
#[test]
fn non_ascii_text_after_escapes_does_not_panic() {
@@ -337,6 +419,12 @@ mod tests {
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
}
/// `\\_` is RTF's non-breaking hyphen: a visible character, not markup.
#[test]
fn non_breaking_hyphen_is_kept() {
assert_eq!(rtf_to_outline(r"{\rtf1\pard Year\_end review\par}"), "Year\u{2011}end review\n");
}
/// A hostile `\li` near `i64::MAX` overflowed the rounding addend in debug builds.
#[test]
fn huge_left_indent_gives_the_deepest_level() {
@@ -355,9 +443,30 @@ mod tests {
assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p));
}
/// A title with two paragraphs: the plain outline keeps both, the RTF export kept only the first.
#[test]
fn every_title_paragraph_is_exported() {
let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false);
crate::outline_to_slides(&mut p, "First line\n\tBody\n");
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
let title = slide.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_mut()).unwrap();
title.paragraphs.push(Paragraph::new("Second line"));
let rtf = slides_to_rtf(&p);
assert!(rtf.contains("First line\\line Second line\\b0\\par"), "{rtf}");
assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p));
}
#[test]
fn reads_word_style_rtf_by_indent_and_hex_escapes() {
let rtf = r"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\generator X;}\pard\b Title one\b0\par\pard\li720 Point \'93a\'94\par\pard\li1440 Sub\par}";
assert_eq!(rtf_to_outline(rtf), "Title one\n\t\tPoint “a”\n\t\t\t\tSub\n");
}
/// Word writes a list item's bullet as `{\\listtext\\pard\\plain …\\'b7\\tab}` (older writers:
/// `{\\pntext …}`) after the paragraph's own `\\li`. That group is the rendered bullet, not text.
#[test]
fn word_list_bullets_are_not_text_and_keep_the_indent() {
let rtf = r"{\rtf1\ansi\pard Title\par\pard\fi-360\li720{\listtext\pard\plain\f1\'b7\tab}Point\par\pard\li720{\pntext\f1\'b7\tab}Old\par}";
assert_eq!(rtf_to_outline(rtf), "Title\n\t\tPoint\n\t\tOld\n");
}
}
+63 -5
View File
@@ -1334,7 +1334,9 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
let (y1, y2) = (h / 2.0 - h * k / 2.0, h / 2.0 + h * k / 2.0);
let dx = (ss * a(1).max(0.0) / 1e5).min(w);
if name == "notchedRightArrow" {
let n = dx * (k / 2.0) * 0.9;
// x1 = dy1 · dx2 / hd2 in presetShapeDefinitions, with dy1 = h·k/2: the notch's
// edges run parallel to the head's
let n = dx * k;
let pts = [(0.0, y1), (w - dx, y1), (w - dx, 0.0), (w, h / 2.0), (w - dx, h), (w - dx, y2), (0.0, y2), (n, h / 2.0)];
g.paths = vec![fill(Pb::new().poly(&pts).take())];
} else {
@@ -1860,10 +1862,10 @@ fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry {
let r = r.min(w / 2.0).min(h / 2.0);
// Walk clockwise from the top-left; insert the wedge on the edge facing the tip.
let horiz = dx.abs() > dy.abs();
let (bx0, bx1) = (w * 5.0 / 12.0, w * 7.0 / 12.0);
let (by0, by1) = (h * 5.0 / 12.0, h * 7.0 / 12.0);
let (bx0, bx1) = if tip.x < w / 2.0 { (w * 2.0 / 12.0, w * 5.0 / 12.0) } else { (bx0 + w / 12.0 * 2.0 - w / 12.0 * 2.0, bx1 + w * 2.0 / 12.0) };
let (by0, by1) = if tip.y < h / 2.0 { (h * 2.0 / 12.0, h * 5.0 / 12.0) } else { (by0, by1 + h * 2.0 / 12.0) };
// The wedge's base: 2..5 twelfths of the edge on the tip's side of the centre when it is
// before it, 7..10 twelfths when after (presetShapeDefinitions' xg1/xg2, yg1/yg2).
let (bx0, bx1) = if tip.x < w / 2.0 { (w * 2.0 / 12.0, w * 5.0 / 12.0) } else { (w * 7.0 / 12.0, w * 10.0 / 12.0) };
let (by0, by1) = if tip.y < h / 2.0 { (h * 2.0 / 12.0, h * 5.0 / 12.0) } else { (h * 7.0 / 12.0, h * 10.0 / 12.0) };
b.m(r, 0.0);
if !inside && !horiz && dy < 0.0 {
b.l(bx0, 0.0).l(tip.x, tip.y).l(bx1, 0.0);
@@ -2454,6 +2456,20 @@ mod tests {
assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6));
}
#[test]
fn notched_arrow_notch_parallels_the_head() {
// presetShapeDefinitions: the notch's point is at x1 = dy1 · dx2 / hd2, so its edges run
// parallel to the head's (25 on a 200 × 100 arrow at the default adjusts).
for (w, h, adj, x1) in [(200.0, 100.0, [50000.0, 50000.0], 25.0), (300.0, 120.0, [80000.0, 30000.0], 28.8)] {
let g = build("notchedRightArrow", w, h, &adj).unwrap();
let notch = g.paths[0].path.elements().iter().find_map(|e| match e {
kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) if (p.y - h / 2.0).abs() < 1e-9 && p.x < w / 2.0 => Some(*p),
_ => None,
});
assert!(notch.is_some_and(|p| (p.x - x1).abs() < 1e-6), "{w}x{h} {adj:?}: notch at {notch:?}, want x = {x1}");
}
}
#[test]
fn equation_shapes_match_powerpoints_outlines() {
// Corners read off PowerPoint's own outlines of the presets at their default adjusts.
@@ -2590,4 +2606,46 @@ mod tests {
assert!(g.paths.iter().any(|p| p.stroke && p.fill == FillMode::None), "{name}: the curve is outlined");
}
}
/// The end points of a path's move/line elements, in order.
fn vertices(p: &BezPath) -> Vec<Point> {
p.elements()
.iter()
.filter_map(|e| match e {
kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => Some(*q),
_ => None,
})
.collect()
}
#[test]
fn wedge_callout_mirrors_with_its_tip() {
// A tip below and right of the centre draws the mirror image of the same tip on the left;
// the same for a tip right of the centre, above or below it. presetShapeDefinitions puts
// the wedge's base at 2..5 twelfths of the edge on the near side of the centre and at
// 7..10 twelfths on the far side.
let (w, h) = (240.0, 120.0);
// Sorted and deduplicated: the outline closes on the corner it starts from, which the
// mirror moves to the opposite corner.
let sorted = |mut v: Vec<Point>| {
v.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
v.dedup_by(|a, b| (*a - *b).hypot() < 1e-9);
v
};
for (right, left, flip_x) in [([10000.0, 80000.0], [-10000.0, 80000.0], true), ([80000.0, 10000.0], [80000.0, -10000.0], false)] {
let a = vertices(&build("wedgeRectCallout", w, h, &right).unwrap().paths[0].path);
let b = vertices(&build("wedgeRectCallout", w, h, &left).unwrap().paths[0].path);
let b: Vec<Point> = b.iter().map(|p| if flip_x { Point::new(w - p.x, p.y) } else { Point::new(p.x, h - p.y) }).collect();
let (a, b) = (sorted(a), sorted(b));
assert_eq!(a.len(), b.len(), "{right:?}: {a:?} vs mirrored {left:?}: {b:?}");
for (p, q) in a.iter().zip(&b) {
assert!((*p - *q).hypot() < 1e-6, "{right:?}: {a:?}\n is not the mirror of {left:?}: {b:?}");
}
}
// The bottom wedge of a tip right of the centre leaves the edge at 7/12 and 10/12 of the width.
let a = vertices(&build("wedgeRectCallout", w, h, &[10000.0, 80000.0]).unwrap().paths[0].path);
for x in [w * 7.0 / 12.0, w * 10.0 / 12.0] {
assert!(a.iter().any(|p| (p.x - x).abs() < 1e-6 && (p.y - h).abs() < 1e-6), "no wedge corner at ({x}, {h}): {a:?}");
}
}
}
+4 -1
View File
@@ -2,7 +2,10 @@
//! ribbons, gears, tabs and chart markers. Like the rest of `preset`, every outline is our own
//! construction, fitted to how the shapes look and to what their adjust values mean.
use super::*;
use super::{
Corner, Dir, FillMode, Geometry, Handle, Pb, Point, Rect, Vec2, corner_rect, fill, fill_eo, fill_only, mid_sites, orient, shade, simple,
stroke_only, vis,
};
/// Builds the presets this module knows; `None` for any other name.
pub(super) fn build(name: &str, w: f64, h: f64, a: &dyn Fn(usize) -> f64) -> Option<Geometry> {
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
Band, BezPath, CATALOG, Guide, Point, Rect, WarpLine, adjust_count, adjust_defaults, band_of_line, handles, is_follow, is_supported, warp,
};
const W: f64 = 420.0;
const H: f64 = 200.0;
+202 -143
View File
@@ -3,7 +3,7 @@
use crate::error::{Result, ensure};
use crate::picture::{FrameRef, RefPic};
use crate::slice::{Mmco, SliceHeader, SliceType};
use crate::slice::{Mmco, RefPicListMod, SliceHeader, SliceType};
use std::sync::Arc;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -174,6 +174,103 @@ impl Dpb {
}
}
/// Initial reference picture lists (8.2.4.2) as DPB entries.
fn initial_entry_lists<'a>(
sh: &SliceHeader,
cur_poc: i32,
short: &[&'a DpbEntry],
long: &[&'a DpbEntry],
pic_num: impl Fn(&DpbEntry) -> i32,
) -> [Vec<&'a DpbEntry>; 2] {
let mut entry_lists: [Vec<&'a DpbEntry>; 2] = [Vec::new(), Vec::new()];
if sh.slice_type != SliceType::B {
let mut s = short.to_vec();
s.sort_by_key(|e| std::cmp::Reverse(pic_num(e)));
entry_lists[0] = s.into_iter().chain(long.iter().copied()).collect();
} else {
let mut before: Vec<&'a DpbEntry> = short.iter().copied().filter(|e| e.poc < cur_poc).collect();
let mut after: Vec<&'a DpbEntry> = short.iter().copied().filter(|e| e.poc > cur_poc).collect();
before.sort_by_key(|e| std::cmp::Reverse(e.poc));
after.sort_by_key(|e| e.poc);
entry_lists[0] = before.iter().chain(after.iter()).chain(long.iter()).copied().collect();
entry_lists[1] = after.iter().chain(before.iter()).chain(long.iter()).copied().collect();
if entry_lists[1].len() > 1 && entry_lists[0].len() == entry_lists[1].len() {
let same = entry_lists[0].iter().zip(entry_lists[1].iter()).all(|(a, b)| std::ptr::eq(*a, *b));
if same {
entry_lists[1].swap(0, 1);
}
}
}
entry_lists
}
/// Reference picture list modification (8.2.4.3) of one list; `n` is num_ref_idx_active.
fn modify_ref_list<'a>(
list: &mut Vec<Option<&'a DpbEntry>>,
mods: &[RefPicListMod],
n: usize,
cur_fn: u32,
max_frame_num: u32,
short: &[&'a DpbEntry],
long: &[&'a DpbEntry],
) -> Result<()> {
let max_pic_num = max_frame_num as i32;
let curr_pic_num = cur_fn as i32;
let mut pred = curr_pic_num;
let mut ref_idx = 0usize;
for m in mods {
ensure!(ref_idx < n, "too many ref_pic_list_modification entries");
list.push(None); // temporarily one longer
let (target, is_long): (Option<&DpbEntry>, bool) = match m.idc {
0 | 1 => {
let d = m.value as i32 + 1;
let no_wrap = if m.idc == 0 {
if pred - d < 0 { pred - d + max_pic_num } else { pred - d }
} else if pred + d >= max_pic_num {
pred + d - max_pic_num
} else {
pred + d
};
pred = no_wrap;
let pn = if no_wrap > curr_pic_num { no_wrap - max_pic_num } else { no_wrap };
(short.iter().copied().find(|e| Self::frame_num_wrap(e, cur_fn, max_frame_num) == pn), false)
}
2 => (long.iter().copied().find(|e| e.long_term_frame_idx == m.value), true),
_ => continue,
};
let Some(target) = target else {
// Missing picture: keep the list unchanged (error concealment).
list.pop();
ref_idx += 1;
continue;
};
for c in (ref_idx + 1..=n).rev() {
list[c] = list[c - 1];
}
list[ref_idx] = Some(target);
ref_idx += 1;
let mut nidx = ref_idx;
for c in ref_idx..=n {
let keep = match list[c] {
None => true,
Some(e) => {
if is_long {
!(e.mark == RefMark::Long && e.long_term_frame_idx == target.long_term_frame_idx)
} else {
!(e.mark == RefMark::Short && std::ptr::eq(e, target))
}
}
};
if keep {
list[nidx] = list[c];
nidx += 1;
}
}
list.truncate(n);
}
Ok(())
}
/// Initial reference picture lists (8.2.4.2) + modification (8.2.4.3) for a frame slice.
pub fn build_ref_lists(&self, sh: &SliceHeader, cur_poc: i32, max_frame_num: u32) -> Result<[Vec<RefPic>; 2]> {
let mut lists: [Vec<RefPic>; 2] = [Vec::new(), Vec::new()];
@@ -186,25 +283,7 @@ impl Dpb {
let mut long: Vec<&DpbEntry> = self.entries.iter().filter(|e| e.mark == RefMark::Long).collect();
long.sort_by_key(|e| e.long_term_frame_idx);
let to_ref = |e: &&DpbEntry| RefPic { frame: e.frame.clone(), long_term: e.mark == RefMark::Long };
let mut entry_lists: [Vec<&DpbEntry>; 2] = [Vec::new(), Vec::new()];
if sh.slice_type != SliceType::B {
let mut s = short.clone();
s.sort_by_key(|e| std::cmp::Reverse(pic_num(e)));
entry_lists[0] = s.into_iter().chain(long.iter().copied()).collect();
} else {
let mut before: Vec<&DpbEntry> = short.iter().copied().filter(|e| e.poc < cur_poc).collect();
let mut after: Vec<&DpbEntry> = short.iter().copied().filter(|e| e.poc > cur_poc).collect();
before.sort_by_key(|e| std::cmp::Reverse(e.poc));
after.sort_by_key(|e| e.poc);
entry_lists[0] = before.iter().chain(after.iter()).chain(long.iter()).copied().collect();
entry_lists[1] = after.iter().chain(before.iter()).chain(long.iter()).copied().collect();
if entry_lists[1].len() > 1 && entry_lists[0].len() == entry_lists[1].len() {
let same = entry_lists[0].iter().zip(entry_lists[1].iter()).all(|(a, b)| std::ptr::eq(*a, *b));
if same {
entry_lists[1].swap(0, 1);
}
}
}
let entry_lists = Self::initial_entry_lists(sh, cur_poc, &short, &long, pic_num);
let nlists = if sh.slice_type == SliceType::B { 2 } else { 1 };
for l in 0..nlists {
let n = sh.num_ref_idx_active[l] as usize;
@@ -212,60 +291,7 @@ impl Dpb {
list.truncate(n);
list.resize(n, None);
if !sh.ref_pic_list_mod[l].is_empty() {
let max_pic_num = max_frame_num as i32;
let curr_pic_num = cur_fn as i32;
let mut pred = curr_pic_num;
let mut ref_idx = 0usize;
for m in &sh.ref_pic_list_mod[l] {
ensure!(ref_idx < n, "too many ref_pic_list_modification entries");
list.push(None); // temporarily one longer
let (target, is_long): (Option<&DpbEntry>, bool) = match m.idc {
0 | 1 => {
let d = m.value as i32 + 1;
let no_wrap = if m.idc == 0 {
if pred - d < 0 { pred - d + max_pic_num } else { pred - d }
} else if pred + d >= max_pic_num {
pred + d - max_pic_num
} else {
pred + d
};
pred = no_wrap;
let pn = if no_wrap > curr_pic_num { no_wrap - max_pic_num } else { no_wrap };
(short.iter().copied().find(|e| pic_num(e) == pn), false)
}
2 => (long.iter().copied().find(|e| e.long_term_frame_idx == m.value), true),
_ => continue,
};
let Some(target) = target else {
// Missing picture: keep the list unchanged (error concealment).
list.pop();
ref_idx += 1;
continue;
};
for c in (ref_idx + 1..=n).rev() {
list[c] = list[c - 1];
}
list[ref_idx] = Some(target);
ref_idx += 1;
let mut nidx = ref_idx;
for c in ref_idx..=n {
let keep = match list[c] {
None => true,
Some(e) => {
if is_long {
!(e.mark == RefMark::Long && e.long_term_frame_idx == target.long_term_frame_idx)
} else {
!(e.mark == RefMark::Short && std::ptr::eq(e, target))
}
}
};
if keep {
list[nidx] = list[c];
nidx += 1;
}
}
list.truncate(n);
}
Self::modify_ref_list(&mut list, &sh.ref_pic_list_mod[l], n, cur_fn, max_frame_num, &short, &long)?;
}
// Fill missing entries (should not happen in conforming streams) with the first valid entry.
let fallback = list.iter().flatten().next().copied().or_else(|| self.entries.last());
@@ -275,58 +301,63 @@ impl Dpb {
Ok(lists)
}
/// Adaptive memory control (8.2.5.4). Returns true if MMCO 5 was executed.
fn apply_mmcos(&mut self, mmcos: &[Mmco], cur_frame_num: u32, max_frame_num: u32, cur_long: &mut Option<u32>) -> bool {
/// One memory management control operation; returns true for MMCO 5.
fn apply_mmco(&mut self, m: &Mmco, cur_frame_num: u32, max_frame_num: u32, cur_long: &mut Option<u32>) -> bool {
let mut had5 = false;
let curr_pic_num = cur_frame_num as i32;
for m in mmcos {
match m.op {
1 => {
let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1);
for e in &mut self.entries {
if e.mark == RefMark::Short && Self::frame_num_wrap(e, cur_frame_num, max_frame_num) == pn {
e.mark = RefMark::Unused;
}
}
}
2 => {
for e in &mut self.entries {
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_pic_num {
e.mark = RefMark::Unused;
}
}
}
3 => {
let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1);
for e in &mut self.entries {
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx {
e.mark = RefMark::Unused;
}
}
for e in &mut self.entries {
if e.mark == RefMark::Short && Self::frame_num_wrap(e, cur_frame_num, max_frame_num) == pn {
e.mark = RefMark::Long;
e.long_term_frame_idx = m.long_term_frame_idx;
}
}
}
4 => {
let max = m.max_long_term_frame_idx_plus1;
for e in &mut self.entries {
if e.mark == RefMark::Long && (max == 0 || e.long_term_frame_idx > max - 1) {
e.mark = RefMark::Unused;
}
}
self.max_long_term_frame_idx = if max == 0 { None } else { Some(max - 1) };
}
5 => {
for e in &mut self.entries {
match m.op {
1 => {
let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1);
for e in &mut self.entries {
if e.mark == RefMark::Short && Self::frame_num_wrap(e, cur_frame_num, max_frame_num) == pn {
e.mark = RefMark::Unused;
}
self.max_long_term_frame_idx = None;
had5 = true;
}
6 => {
}
2 => {
for e in &mut self.entries {
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_pic_num {
e.mark = RefMark::Unused;
}
}
}
3 => {
let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1);
for e in &mut self.entries {
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx {
e.mark = RefMark::Unused;
}
}
for e in &mut self.entries {
if e.mark == RefMark::Short && Self::frame_num_wrap(e, cur_frame_num, max_frame_num) == pn {
e.mark = RefMark::Long;
e.long_term_frame_idx = m.long_term_frame_idx;
}
}
}
4 => {
let max = m.max_long_term_frame_idx_plus1;
for e in &mut self.entries {
if e.mark == RefMark::Long && (max == 0 || e.long_term_frame_idx > max - 1) {
e.mark = RefMark::Unused;
}
}
self.max_long_term_frame_idx = if max == 0 { None } else { Some(max - 1) };
}
5 => {
for e in &mut self.entries {
e.mark = RefMark::Unused;
}
self.max_long_term_frame_idx = None;
had5 = true;
}
6 => {
// Only an index within the allowed range may be assigned (8.2.5.4.6).
let allowed = match self.max_long_term_frame_idx {
Some(max) => m.long_term_frame_idx <= max,
None => false,
};
if allowed {
for e in &mut self.entries {
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx {
e.mark = RefMark::Unused;
@@ -334,26 +365,24 @@ impl Dpb {
}
*cur_long = Some(m.long_term_frame_idx);
}
_ => {}
}
_ => {}
}
had5
}
/// Adaptive memory control (8.2.5.4). Returns true if MMCO 5 was executed.
fn apply_mmcos(&mut self, mmcos: &[Mmco], cur_frame_num: u32, max_frame_num: u32, cur_long: &mut Option<u32>) -> bool {
let mut had5 = false;
for m in mmcos {
had5 |= self.apply_mmco(m, cur_frame_num, max_frame_num, cur_long);
}
self.prune();
had5
}
/// Reference marking for the just-decoded picture and insertion into the DPB with output bumping.
/// Returns pictures ready for output.
#[allow(clippy::too_many_arguments)]
pub fn store_picture(
&mut self,
sh: &SliceHeader,
frame: FrameRef,
poc: i32,
max_frame_num: u32,
max_num_ref_frames: usize,
meta: Arc<OutputMeta>,
out: &mut Vec<Output>,
) {
/// Reference marking of the current picture (8.2.5); returns (mark, long-term idx, MMCO 5 executed).
fn mark_current(&mut self, sh: &SliceHeader, max_frame_num: u32, max_num_ref_frames: usize) -> (RefMark, u32, bool) {
let mut mark = RefMark::Unused;
let mut long_idx = 0;
let mut had5 = false;
@@ -385,12 +414,12 @@ impl Dpb {
}
}
}
if had5 {
// All earlier pictures are output before the current one (C.4.4).
self.flush(out);
}
let frame_num = if had5 { 0 } else { sh.frame_num };
// Bumping until there is space (C.4.5.1 / C.4.5.2).
(mark, long_idx, had5)
}
/// Bumping until there is space (C.4.5.1 / C.4.5.2). Returns true when the (non-reference) current
/// picture has the smallest POC and is to be output directly instead of being stored.
fn bump_for_space(&mut self, mark: RefMark, poc: i32, out: &mut Vec<Output>) -> bool {
if mark == RefMark::Unused {
loop {
let waiting = self.entries.len();
@@ -400,8 +429,7 @@ impl Dpb {
// non-reference picture with the smallest POC is output directly
let min_poc = self.entries.iter().filter(|e| e.needed_for_output).map(|e| e.poc).min();
if min_poc.is_none_or(|m| poc < m) {
out.push(Output { frame, poc, meta });
return;
return true;
}
if !self.bump(out) {
break;
@@ -410,10 +438,41 @@ impl Dpb {
} else {
while self.entries.len() >= self.capacity {
if !self.bump(out) {
if let Some(pos) = self.entries.iter().position(|e| !e.needed_for_output) {
self.entries.remove(pos);
} else {
self.entries.remove(0);
}
break;
}
}
}
false
}
/// Reference marking for the just-decoded picture and insertion into the DPB with output bumping.
/// Returns pictures ready for output.
#[allow(clippy::too_many_arguments)]
pub fn store_picture(
&mut self,
sh: &SliceHeader,
frame: FrameRef,
poc: i32,
max_frame_num: u32,
max_num_ref_frames: usize,
meta: Arc<OutputMeta>,
out: &mut Vec<Output>,
) {
let (mark, long_idx, had5) = self.mark_current(sh, max_frame_num, max_num_ref_frames);
if had5 {
// All earlier pictures are output before the current one (C.4.4).
self.flush(out);
}
let frame_num = if had5 { 0 } else { sh.frame_num };
if self.bump_for_space(mark, poc, out) {
out.push(Output { frame, poc, meta });
return;
}
self.entries.push(DpbEntry {
frame,
poc,
+1 -1
View File
@@ -575,7 +575,7 @@ impl Pps {
ensure!(sps_id < 32, "seq_parameter_set_id out of range");
let sps = spss
.get(sps_id as usize)
.and_then(|s| s.as_ref())
.and_then(Option::as_ref)
.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()?;
+1 -1
View File
@@ -250,7 +250,7 @@ impl Frame {
/// Copy the w x h window of chroma component `c` at (x0, y0) (clamped) into `out`.
#[inline]
pub fn chroma_window(&self, c: usize, x0: i32, y0: i32, w: usize, h: usize, out: &mut [u8], os: usize) {
pub fn chroma_window(&self, c: usize, (x0, y0): (i32, i32), (w, h): (usize, usize), out: &mut [u8], os: usize) {
let (cw, ch) = (self.cwidth, self.cheight);
for r in 0..h {
let yy = (y0 + r as i32).clamp(0, ch as i32 - 1) as usize;
+39 -25
View File
@@ -232,7 +232,7 @@ fn predict_block(f: &Frame, mv: [i16; 2], px: usize, py: usize, bw: usize, bh: u
let (fx, fy) = ((mv[0] & 7) as u32, (mv[1] & 7) as u32);
for (comp, out) in c.into_iter().enumerate() {
let mut w = [0u8; 16 * 9];
f.chroma_window(comp, cx, cy, cw + 1, ch + 1, &mut w, 16);
f.chroma_window(comp, (cx, cy), (cw + 1, ch + 1), &mut w, 16);
inter::mc_chroma_win(&w, 16, fx, fy, cw, ch, out, cs);
}
}
@@ -665,33 +665,38 @@ impl<'a> SliceDecoder<'a> {
return Ok(());
}
for p in 0..4 {
let sub = self.cur.sub[p];
let (x8, y8) = ((p & 1) * 2, (p >> 1) * 2);
if sub.direct {
self.derive_direct(1 << p)?;
continue;
self.derive_sub_mb_motion(p, n)?;
}
Ok(())
}
/// Motion of one 8x8 sub-macroblock `p` of a P8x8/B8x8 MB.
fn derive_sub_mb_motion(&mut self, p: usize, n: [usize; 2]) -> Result<()> {
let sub = self.cur.sub[p];
let (x8, y8) = ((p & 1) * 2, (p >> 1) * 2);
if sub.direct {
return self.derive_direct(1 << p);
}
let mut refs = [-1i8; 2];
for (l, r) in refs.iter_mut().enumerate() {
if sub.pred & (1 << l) != 0 {
let v = self.cur.ref_idx[l][p];
ensure!((v as usize) < n[l], "ref_idx {v} out of range (list {l}, size {})", n[l]);
*r = v;
}
let mut refs = [-1i8; 2];
for (l, r) in refs.iter_mut().enumerate() {
if sub.pred & (1 << l) != 0 {
let v = self.cur.ref_idx[l][p];
ensure!((v as usize) < n[l], "ref_idx {v} out of range (list {l}, size {})", n[l]);
*r = v;
}
for j in 0..sub.shape.num_parts() {
let (sx, sy, w, h) = sub.shape.rect(j);
let (x, y) = (x8 + sx, y8 + sy);
let mut mvs = [[0i16; 2]; 2];
for l in 0..2 {
if refs[l] >= 0 {
let mvp = self.mvp(l, x, y, w, refs[l], 0, 0);
let d = self.cur.mvd[l][p * 4 + j];
mvs[l] = [mvp[0].wrapping_add(d[0]), mvp[1].wrapping_add(d[1])];
}
}
for j in 0..sub.shape.num_parts() {
let (sx, sy, w, h) = sub.shape.rect(j);
let (x, y) = (x8 + sx, y8 + sy);
let mut mvs = [[0i16; 2]; 2];
for l in 0..2 {
if refs[l] >= 0 {
let mvp = self.mvp(l, x, y, w, refs[l], 0, 0);
let d = self.cur.mvd[l][p * 4 + j];
mvs[l] = [mvp[0].wrapping_add(d[0]), mvp[1].wrapping_add(d[1])];
}
}
self.set_motion(x, y, w, h, refs, mvs);
}
self.set_motion(x, y, w, h, refs, mvs);
}
Ok(())
}
@@ -1380,6 +1385,11 @@ impl<'a> SliceDecoder<'a> {
cavlc::residual_block(r, &mut lv, 0, 15, 16, nc)?;
self.put_luma_dc(&lv);
}
self.residual_cavlc_luma(r, cbp, kind, t8)?;
self.residual_cavlc_chroma(r, cbp)
}
fn residual_cavlc_luma(&mut self, r: &mut BitReader, cbp: u8, kind: MbKind, t8: bool) -> Result<()> {
for b8 in 0..4 {
let (bx8, by8) = ((b8 & 1) * 2, (b8 >> 1) * 2);
if cbp & (1 << b8) == 0 {
@@ -1413,6 +1423,10 @@ impl<'a> SliceDecoder<'a> {
}
}
}
Ok(())
}
fn residual_cavlc_chroma(&mut self, r: &mut BitReader, cbp: u8) -> Result<()> {
let cbp_c = cbp >> 4;
if cbp_c & 3 != 0 {
for c in 0..2 {
+39
View File
@@ -91,11 +91,21 @@ fn clip_u8(v: i32) -> u8 {
v.clamp(0, 255) as u8
}
/// A dequantised coefficient the inverse transforms are defined for: the dequantisation wraps
/// by design (see [`huge_levels_dequantise_without_overflow_panics`]), so their butterflies
/// clamp an r outside −2^15..2^15−1 to this range — where the spec leaves the output
/// unspecified anyway (8.5.12.1, 8.5.13.1) — instead of overflowing the plain i32 additions.
#[inline(always)]
fn r_clamp(v: i32) -> i32 {
v.clamp(-(1 << 15), (1 << 15) - 1)
}
/// Inverse 4x4 transform of raster-order `d` and add to `dst` (8.5.12.2, 8.5.14).
///
/// Rows first, then columns (the spec's order: the `>> 1` steps make it matter). The column
/// pass and the reconstruction run across all columns at once so they vectorise.
pub fn idct4_add(d: &[i32; 16], dst: &mut [u8], stride: usize) {
let d: [i32; 16] = std::array::from_fn(|i| r_clamp(d[i]));
let mut t = [[0i32; 4]; 4];
for (i, row) in t.iter_mut().enumerate() {
let r = &d[i * 4..i * 4 + 4];
@@ -161,6 +171,7 @@ fn idct8_1d(d: [i32; 8]) -> [i32; 8] {
/// Inverse 8x8 transform of raster-order `d` and add to `dst` (8.5.13.2): rows, then columns,
/// the column pass and reconstruction across all columns at once (they vectorise).
pub fn idct8_add(d: &[i32; 64], dst: &mut [u8], stride: usize) {
let d: [i32; 64] = std::array::from_fn(|i| r_clamp(d[i]));
let mut t = [[0i32; 8]; 8];
for (row, src) in t.iter_mut().zip(d.as_chunks::<8>().0) {
*row = idct8_1d(*src);
@@ -304,4 +315,32 @@ mod tests {
let _ = scale8(i32::MIN, 16 * 25, 51);
let _ = scale8(i32::MIN, 16 * 25, 0);
}
/// The same wrapping dequantisation hands the inverse transforms any i32 coefficient, and
/// their butterflies add in plain i32: `i32::MAX + i32::MAX` panicked a debug build. The
/// spec leaves the transforms' output unspecified when a dequantised value falls outside
/// −2^15..2^15−1 (8.5.12.1, 8.5.13.1), so those inputs are clamped to that range —
/// bit-exact for every stream inside it — instead of panicking.
#[test]
fn huge_coefficients_are_clamped_to_the_spec_r_range() {
assert_eq!(r_clamp(-32769), -32768);
assert_eq!(r_clamp(-32768), -32768);
assert_eq!(r_clamp(32767), 32767);
assert_eq!(r_clamp(32768), 32767);
let clamp = |v: i32| v.clamp(-(1 << 15), (1 << 15) - 1);
for d in [[i32::MAX; 16], [i32::MIN; 16], std::array::from_fn(|i| if i % 2 == 0 { i32::MIN } else { i32::MAX })] {
let clamped = std::array::from_fn(|i| clamp(d[i]));
let (mut a, mut b) = ([0u8; 64], [0u8; 64]);
idct4_add(&d, &mut a, 8);
idct4_add(&clamped, &mut b, 8);
assert_eq!(a, b, "idct4 of extremes equals idct4 of the spec range");
}
for d in [[i32::MIN; 64], [i32::MAX; 64]] {
let clamped = std::array::from_fn(|i| clamp(d[i]));
let (mut a, mut b) = ([0u8; 64], [0u8; 64]);
idct8_add(&d, &mut a, 8);
idct8_add(&clamped, &mut b, 8);
assert_eq!(a, b, "idct8 of extremes equals idct8 of the spec range");
}
}
}
+12 -1
View File
@@ -440,11 +440,22 @@ impl Decoder {
let template = Frame::new(0, 0, w, h, sps.log2_ctb, sps.bit_depth_luma, sps.bit_depth_chroma);
let next_id = &mut self.next_id;
let mut missing = 0u64;
let max_missing = (sps.max_dec_pic_buffering as u64).clamp(1, 16);
let mut shared_gray: Option<FrameRef> = None;
let mut make_missing = |p: i32| -> FrameRef {
missing += 1;
if missing > max_missing
&& let Some(ref g) = shared_gray
{
return g.clone();
}
let id = *next_id;
*next_id += 1;
Arc::new(Frame::gray(id, p, &template))
let f = Arc::new(Frame::gray(id, p, &template));
if shared_gray.is_none() {
shared_gray = Some(f.clone());
}
f
};
let rps = self.dpb.apply_rps(sh, poc, max_lsb, irap_no_rasl, &mut make_missing)?;
if missing > 0 {
+4 -9
View File
@@ -128,7 +128,7 @@ pub fn mc_chroma(f: &Frame, c: usize, x: i32, y: i32, mv: [i16; 2], w: usize, h:
let yi = y + (mv[1] as i32 >> 3);
let shift1 = bd.min(12) - 8;
let shift3 = 14 - bd;
f.chroma_window(c, xi - 1, yi - 1, w + 3, h + 3, &mut sc.win, WIN);
f.chroma_window(c, (xi - 1, yi - 1), (w + 3, h + 3), &mut sc.win, WIN);
interpolate::<4>(&sc.win, &mut sc.tmp, fx, fy, &CHROMA_FILTER[fx], &CHROMA_FILTER[fy], w, h, shift1, shift3, out);
}
@@ -158,7 +158,7 @@ pub fn put_bi(p0: &[i16], p1: &[i16], w: usize, h: usize, bd: u32, dst: &mut [u1
}
/// Explicit weighted prediction, one list (8-265). `o` is the offset already scaled to the bit depth.
pub fn put_weighted_uni(p: &[i16], w: usize, h: usize, bd: u32, log2wd: u32, wt: i32, o: i32, dst: &mut [u16], ds: usize) {
pub fn put_weighted_uni(p: &[i16], (w, h): (usize, usize), bd: u32, (log2wd, wt, o): (u32, i32, i32), dst: &mut [u16], ds: usize) {
let max = (1i32 << bd) - 1;
for r in 0..h {
for c in 0..w {
@@ -173,14 +173,9 @@ pub fn put_weighted_uni(p: &[i16], w: usize, h: usize, bd: u32, log2wd: u32, wt:
pub fn put_weighted_bi(
p0: &[i16],
p1: &[i16],
w: usize,
h: usize,
(w, h): (usize, usize),
bd: u32,
log2wd: u32,
w0: i32,
w1: i32,
o0: i32,
o1: i32,
(log2wd, w0, w1, o0, o1): (u32, i32, i32, i32, i32),
dst: &mut [u16],
ds: usize,
) {
+73 -63
View File
@@ -36,9 +36,23 @@ fn same_motion(a: &MvField, b: &MvField) -> bool {
true
}
/// Coding block and prediction block geometry (6.4.2): coding block (xc, yc, ncb), prediction block (xp, yp, w, h, part_idx).
#[derive(Clone, Copy)]
pub struct PbGeom {
pub xc: i32,
pub yc: i32,
pub ncb: i32,
pub xp: i32,
pub yp: i32,
pub w: i32,
pub h: i32,
pub part_idx: usize,
}
impl<'a> SliceDecoder<'a> {
/// Prediction block availability (6.4.2).
fn pb_avail(&self, xc: i32, yc: i32, ncb: i32, xp: i32, yp: i32, w: i32, h: i32, part_idx: usize, xn: i32, yn: i32) -> bool {
fn pb_avail(&self, g: &PbGeom, xn: i32, yn: i32) -> bool {
let PbGeom { xc, yc, ncb, xp, yp, w, h, part_idx } = *g;
let same_cb = xc <= xn && yc <= yn && xc + ncb > xn && yc + ncb > yn;
let avail = if !same_cb {
self.pic.zavail(xp, yp, xn, yn, self.slice_addr)
@@ -59,27 +73,40 @@ impl<'a> SliceDecoder<'a> {
}
/// Merge mode motion (8.5.3.2.2).
pub fn derive_merge(
&self,
xc: i32,
yc: i32,
ncb: i32,
xp0: i32,
yp0: i32,
w0: i32,
h0: i32,
part_idx0: usize,
part: PartMode,
merge_idx: usize,
) -> MvField {
pub fn derive_merge(&self, g: PbGeom, part: PartMode, merge_idx: usize) -> MvField {
let (w0, h0) = (g.w, g.h);
// With a parallel merge level above 4x4, an 8x8 coding block shares one candidate set.
let g = if self.pps.log2_parallel_merge_level > 2 && g.ncb == 8 {
PbGeom { xp: g.xc, yp: g.yc, w: g.ncb, h: g.ncb, part_idx: 0, ..g }
} else {
g
};
let max = self.sh.max_num_merge_cand as usize;
let (list, n) = self.spatial_merge_candidates(&g, part);
if merge_idx < n {
return self.finish_merge(list[merge_idx], w0, h0);
}
let mut cands: Vec<MvField> = list[..n].to_vec();
cands.extend(self.temporal_merge_candidate(g.xp, g.yp, g.w, g.h));
if merge_idx < cands.len() {
return self.finish_merge(cands[merge_idx], w0, h0);
}
self.combined_merge_candidates(&mut cands, max);
if merge_idx < cands.len() {
return self.finish_merge(cands[merge_idx], w0, h0);
}
self.zero_merge_candidates(&mut cands, merge_idx);
self.finish_merge(cands[merge_idx], w0, h0)
}
/// The spatial candidates A1, B1, B0, A0 and B2 (8.5.3.2.3): the candidates, and how many.
fn spatial_merge_candidates(&self, g: &PbGeom, part: PartMode) -> ([MvField; 5], usize) {
let PbGeom { xp, yp, w, h, part_idx, .. } = *g;
let pml = self.pps.log2_parallel_merge_level;
let (xp, yp, w, h, part_idx) = if pml > 2 && ncb == 8 { (xc, yc, ncb, ncb, 0) } else { (xp0, yp0, w0, h0, part_idx0) };
let same_mer = |xn: i32, yn: i32| (xp >> pml) == (xn >> pml) && (yp >> pml) == (yn >> pml);
let avail = |xn: i32, yn: i32| self.pb_avail(xc, yc, ncb, xp, yp, w, h, part_idx, xn, yn) && !same_mer(xn, yn);
let avail = |xn: i32, yn: i32| self.pb_avail(g, xn, yn) && !same_mer(xn, yn);
let mut list: [MvField; 5] = [MvField::default(); 5];
let mut n = 0;
let max = self.sh.max_num_merge_cand as usize;
// spatial candidates (8.5.3.2.3)
let pa1 = (xp - 1, yp + h - 1);
let av_a1 = avail(pa1.0, pa1.1) && !(matches!(part, PartMode::PNx2N | PartMode::PnLx2N | PartMode::PnRx2N) && part_idx == 1);
let m_a1 = if av_a1 { self.mv_at(pa1.0, pa1.1) } else { MvField::default() };
@@ -126,10 +153,11 @@ impl<'a> SliceDecoder<'a> {
list[n] = self.mv_at(pb2.0, pb2.1);
n += 1;
}
if merge_idx < n {
return self.finish_merge(list[merge_idx], w0, h0);
}
// temporal candidate
(list, n)
}
/// The temporal candidate (8.5.3.2.8): list 0 from the collocated picture, and list 1 for B slices.
fn temporal_merge_candidate(&self, xp: i32, yp: i32, w: i32, h: i32) -> Option<MvField> {
let mut col = MvField::default();
let mut col_avail = false;
if let Some(mv) = self.temporal(xp, yp, w, h, 0, 0) {
@@ -144,30 +172,28 @@ impl<'a> SliceDecoder<'a> {
col.ref_idx[1] = 0;
col_avail = true;
}
let mut cands: Vec<MvField> = list[..n].to_vec();
if col_avail {
cands.push(col);
}
if merge_idx < cands.len() {
return self.finish_merge(cands[merge_idx], w0, h0);
}
// combined bi-predictive candidates (8.5.3.2.4)
col_avail.then_some(col)
}
/// Combined bi-predictive candidates (8.5.3.2.4) from pairs of the original candidates.
fn combined_merge_candidates(&self, cands: &mut Vec<MvField>, max: usize) {
let num_orig = cands.len();
if self.sh.is_b() && num_orig > 1 && num_orig < max {
let mut comb = 0;
while comb < num_orig * (num_orig - 1) && cands.len() < max {
let (i0, i1) = COMB[comb];
let (c0, c1) = (cands[i0], cands[i1]);
if c0.pred(0) && c1.pred(1) && (self.ref_pic(0, c0.ref_idx[0]).poc != self.ref_pic(1, c1.ref_idx[1]).poc || c0.mv[0] != c1.mv[1]) {
cands.push(MvField { mv: [c0.mv[0], c1.mv[1]], ref_idx: [c0.ref_idx[0], c1.ref_idx[1]] });
}
comb += 1;
if !(self.sh.is_b() && num_orig > 1 && num_orig < max) {
return;
}
let mut comb = 0;
while comb < num_orig * (num_orig - 1) && cands.len() < max {
let (i0, i1) = COMB[comb];
let (c0, c1) = (cands[i0], cands[i1]);
if c0.pred(0) && c1.pred(1) && (self.ref_pic(0, c0.ref_idx[0]).poc != self.ref_pic(1, c1.ref_idx[1]).poc || c0.mv[0] != c1.mv[1]) {
cands.push(MvField { mv: [c0.mv[0], c1.mv[1]], ref_idx: [c0.ref_idx[0], c1.ref_idx[1]] });
}
comb += 1;
}
if merge_idx < cands.len() {
return self.finish_merge(cands[merge_idx], w0, h0);
}
// zero candidates (8.5.3.2.5)
}
/// Zero candidates (8.5.3.2.5) up to and including `merge_idx`.
fn zero_merge_candidates(&self, cands: &mut Vec<MvField>, merge_idx: usize) {
let num_ref = if self.sh.is_b() { self.sh.num_ref_idx[0].min(self.sh.num_ref_idx[1]) } else { self.sh.num_ref_idx[0] } as usize;
let mut zero_idx = 0;
while cands.len() <= merge_idx {
@@ -175,7 +201,6 @@ impl<'a> SliceDecoder<'a> {
cands.push(MvField { mv: [[0; 2]; 2], ref_idx: [r, if self.sh.is_b() { r } else { -1 }] });
zero_idx += 1;
}
self.finish_merge(cands[merge_idx], w0, h0)
}
fn finish_merge(&self, mut f: MvField, w: i32, h: i32) -> MvField {
@@ -237,20 +262,8 @@ impl<'a> SliceDecoder<'a> {
}
/// AMVP predictor (8.5.3.2.6, 8.5.3.2.7).
pub fn derive_amvp(
&self,
xc: i32,
yc: i32,
ncb: i32,
xp: i32,
yp: i32,
w: i32,
h: i32,
part_idx: usize,
l: usize,
ref_idx: usize,
mvp_flag: usize,
) -> [i16; 2] {
pub fn derive_amvp(&self, g: PbGeom, l: usize, ref_idx: usize, mvp_flag: usize) -> [i16; 2] {
let PbGeom { xp, yp, w, h, .. } = g;
let target = &self.refs[l][ref_idx];
let (tpoc, tlt) = (target.poc, target.long_term);
let cur_poc = self.pic.poc;
@@ -277,10 +290,7 @@ impl<'a> SliceDecoder<'a> {
pick.map(|(mv, r)| if r.poc != tpoc && !r.long_term && !tlt { scale_mv(mv, cur_poc - r.poc, cur_poc - tpoc) } else { mv })
};
let pa = [(xp - 1, yp + h), (xp - 1, yp + h - 1)];
let av_a = [
self.pb_avail(xc, yc, ncb, xp, yp, w, h, part_idx, pa[0].0, pa[0].1),
self.pb_avail(xc, yc, ncb, xp, yp, w, h, part_idx, pa[1].0, pa[1].1),
];
let av_a = [self.pb_avail(&g, pa[0].0, pa[0].1), self.pb_avail(&g, pa[1].0, pa[1].1)];
let is_scaled = av_a[0] || av_a[1];
let mut a = None;
for k in 0..2 {
@@ -296,7 +306,7 @@ impl<'a> SliceDecoder<'a> {
}
}
let pb = [(xp + w, yp - 1), (xp + w - 1, yp - 1), (xp - 1, yp - 1)];
let av_b = [0, 1, 2].map(|k| self.pb_avail(xc, yc, ncb, xp, yp, w, h, part_idx, pb[k].0, pb[k].1));
let av_b = [0, 1, 2].map(|k| self.pb_avail(&g, pb[k].0, pb[k].1));
let mut b = None;
for k in 0..3 {
if av_b[k] && b.is_none() {
+1 -1
View File
@@ -125,7 +125,7 @@ impl Frame {
}
/// Copy a w x h window of chroma component `c` (0 = Cb, 1 = Cr) with edge replication.
pub fn chroma_window(&self, c: usize, x0: i32, y0: i32, w: usize, h: usize, out: &mut [i16], os: usize) {
pub fn chroma_window(&self, c: usize, (x0, y0): (i32, i32), (w, h): (usize, usize), out: &mut [i16], os: usize) {
let (cw, ch) = (self.cwidth as i32, self.cheight as i32);
let sh = self.log2_ctb - 1;
let mask = (1i32 << sh) - 1;
+249 -208
View File
@@ -7,6 +7,7 @@ use crate::cabac::{Cabac, Contexts, init_contexts};
use crate::error::{Result, ensure, invalid};
use crate::inter;
use crate::intra::{self, Refs};
use crate::mvpred::PbGeom;
use crate::params::{Layout, Pps, Sps};
use crate::picture::{FrameRef, RefPic};
use crate::slice::{SliceHeader, SliceType};
@@ -397,6 +398,37 @@ impl<'a> SliceDecoder<'a> {
d.decode_data()
}
/// Context initialisation / synchronisation at the start of a CTB (9.3.1, 9.3.2.1).
fn init_ctb_contexts(&mut self, layout: &Layout, ts: u32, rs: u32, wctb: u32, first: bool) -> Result<()> {
let ctb = 1i32 << self.sps.log2_ctb;
let first_in_tile = ts == 0 || layout.tile_id[ts as usize] != layout.tile_id[ts as usize - 1];
let wpp_row_start = self.pps.entropy_coding_sync
&& (rs.is_multiple_of(wctb) || layout.tile_id[ts as usize] != layout.tile_id[layout.rs_to_ts[rs as usize - 1] as usize]);
if first_in_tile {
if !first {
init_contexts(&mut self.c.ctx, self.slice_qp, self.init_type);
}
self.last_qp = self.slice_qp;
} else if wpp_row_start {
let (x0, y0) = (self.ctb_x, self.ctb_y);
let avail = self.pic.zavail(x0, y0, x0 + ctb, y0 - ctb, self.slice_addr);
match (&self.pic.wpp_ctx, avail) {
(Some(saved), true) => self.c.ctx = **saved,
_ => init_contexts(&mut self.c.ctx, self.slice_qp, self.init_type),
}
self.last_qp = self.slice_qp;
} else if first && self.sh.dependent {
match &self.pic.ds_ctx {
Some((saved, qp)) => {
self.c.ctx = **saved;
self.last_qp = *qp;
}
None => return invalid("dependent slice segment without stored contexts"),
}
}
Ok(())
}
fn decode_data(&mut self) -> Result<()> {
let layout = self.pic.layout.clone();
let wctb = self.pic.wctb as u32;
@@ -412,32 +444,7 @@ impl<'a> SliceDecoder<'a> {
self.ctb_y = ry * ctb;
self.pic.ctb_slice[rs as usize] = self.slice_idx;
self.pic.ctb_addr[rs as usize] = self.slice_addr;
// context initialisation / synchronisation (9.3.1, 9.3.2.1)
let first_in_tile = ts == 0 || layout.tile_id[ts as usize] != layout.tile_id[ts as usize - 1];
let wpp_row_start = self.pps.entropy_coding_sync
&& (rs.is_multiple_of(wctb) || layout.tile_id[ts as usize] != layout.tile_id[layout.rs_to_ts[rs as usize - 1] as usize]);
if first_in_tile {
if !first {
init_contexts(&mut self.c.ctx, self.slice_qp, self.init_type);
}
self.last_qp = self.slice_qp;
} else if wpp_row_start {
let (x0, y0) = (self.ctb_x, self.ctb_y);
let avail = self.pic.zavail(x0, y0, x0 + ctb, y0 - ctb, self.slice_addr);
match (&self.pic.wpp_ctx, avail) {
(Some(saved), true) => self.c.ctx = **saved,
_ => init_contexts(&mut self.c.ctx, self.slice_qp, self.init_type),
}
self.last_qp = self.slice_qp;
} else if first && self.sh.dependent {
match &self.pic.ds_ctx {
Some((saved, qp)) => {
self.c.ctx = **saved;
self.last_qp = *qp;
}
None => return invalid("dependent slice segment without stored contexts"),
}
}
self.init_ctb_contexts(&layout, ts, rs, wctb, first)?;
first = false;
if self.sh.sao_luma || self.sh.sao_chroma {
self.parse_sao(rx, ry, rs)?;
@@ -506,56 +513,60 @@ impl<'a> SliceDecoder<'a> {
let mut p = SaoParams::default();
for c in 0..3 {
let enabled = if c == 0 { self.sh.sao_luma } else { self.sh.sao_chroma };
if !enabled {
continue;
}
if c == 2 {
p.type_idx[2] = p.type_idx[1];
p.eo_class[2] = p.eo_class[1];
} else {
p.type_idx[c] = if self.dd(SAO_TYPE) == 0 {
0
} else if self.bypass() == 0 {
1
} else {
2
};
}
if p.type_idx[c] == 0 {
continue;
}
let bd = if c == 0 { self.pic.bd_y } else { self.pic.bd_c };
let cmax = (1u32 << (bd.min(10) - 5)) - 1;
let mut abs = [0i16; 4];
for a in abs.iter_mut() {
let mut v = 0;
while v < cmax && self.bypass() == 1 {
v += 1;
}
*a = v as i16;
}
if p.type_idx[c] == 1 {
for a in abs.iter_mut() {
if *a != 0 && self.bypass() == 1 {
*a = -*a;
}
}
p.band_pos[c] = self.bypass_bits(5) as u8;
p.offsets[c] = abs;
} else {
p.offsets[c] = [abs[0], abs[1], -abs[2], -abs[3]];
if c == 0 {
p.eo_class[0] = self.bypass_bits(2) as u8;
}
if c == 1 {
p.eo_class[1] = self.bypass_bits(2) as u8;
}
if enabled {
self.parse_sao_component(&mut p, c);
}
}
self.pic.sao[rs as usize] = p;
Ok(())
}
/// SAO syntax of one enabled colour component `c` (7.3.8.3).
fn parse_sao_component(&mut self, p: &mut SaoParams, c: usize) {
if c == 2 {
p.type_idx[2] = p.type_idx[1];
p.eo_class[2] = p.eo_class[1];
} else {
p.type_idx[c] = if self.dd(SAO_TYPE) == 0 {
0
} else if self.bypass() == 0 {
1
} else {
2
};
}
if p.type_idx[c] == 0 {
return;
}
let bd = if c == 0 { self.pic.bd_y } else { self.pic.bd_c };
let cmax = (1u32 << (bd.min(10) - 5)) - 1;
let mut abs = [0i16; 4];
for a in abs.iter_mut() {
let mut v = 0;
while v < cmax && self.bypass() == 1 {
v += 1;
}
*a = v as i16;
}
if p.type_idx[c] == 1 {
for a in abs.iter_mut() {
if *a != 0 && self.bypass() == 1 {
*a = -*a;
}
}
p.band_pos[c] = self.bypass_bits(5) as u8;
p.offsets[c] = abs;
} else {
p.offsets[c] = [abs[0], abs[1], -abs[2], -abs[3]];
if c == 0 {
p.eo_class[0] = self.bypass_bits(2) as u8;
}
if c == 1 {
p.eo_class[1] = self.bypass_bits(2) as u8;
}
}
}
fn coding_quadtree(&mut self, x0: i32, y0: i32, log2: u32, depth: u32) -> Result<()> {
let size = 1i32 << log2;
let (w, h) = (self.pic.width as i32, self.pic.height as i32);
@@ -668,7 +679,7 @@ impl<'a> SliceDecoder<'a> {
self.mark_edges(x0, y0, n, n, E_TU_V | E_PU_V, E_TU_H | E_PU_H);
if skip {
self.cu_intra = false;
self.prediction_unit(x0, y0, n, x0, y0, n, n, 0, PartMode::P2Nx2N, true)?;
self.prediction_unit(PbGeom { xc: x0, yc: y0, ncb: n, xp: x0, yp: y0, w: n, h: n, part_idx: 0 }, PartMode::P2Nx2N, true)?;
self.finish_cu(x0, y0, n);
return Ok(());
}
@@ -697,7 +708,7 @@ impl<'a> SliceDecoder<'a> {
}
self.parse_intra_modes(x0, y0, n, part)?;
self.max_trafo_depth = self.sps.max_th_depth_intra + self.intra_split as u32;
self.transform_tree(x0, y0, x0, y0, log2, 0, 0, [true, true])?;
self.transform_tree((x0, y0), (x0, y0), log2, 0, 0, [true, true])?;
} else {
self.intra_split = false;
let mut merge2nx2n = false;
@@ -714,7 +725,8 @@ impl<'a> SliceDecoder<'a> {
PartMode::PNxN => &[(0, 0, h, h), (h, 0, h, h), (0, h, h, h), (h, h, h, h)],
};
for (i, &(px, py, pw, ph)) in parts.iter().enumerate() {
let merge = self.prediction_unit(x0, y0, n, x0 + px, y0 + py, pw, ph, i, part, false)?;
let merge =
self.prediction_unit(PbGeom { xc: x0, yc: y0, ncb: n, xp: x0 + px, yp: y0 + py, w: pw, h: ph, part_idx: i }, part, false)?;
if part == PartMode::P2Nx2N {
merge2nx2n = merge;
}
@@ -728,7 +740,7 @@ impl<'a> SliceDecoder<'a> {
let root_cbf = if !merge2nx2n { self.dd(RQT_ROOT_CBF) == 1 } else { true };
if root_cbf {
self.max_trafo_depth = self.sps.max_th_depth_inter;
self.transform_tree(x0, y0, x0, y0, log2, 0, 0, [true, true])?;
self.transform_tree((x0, y0), (x0, y0), log2, 0, 0, [true, true])?;
}
}
self.finish_cu(x0, y0, n);
@@ -826,6 +838,34 @@ impl<'a> SliceDecoder<'a> {
self.c.init_engine()
}
/// Most probable luma intra modes of the block at (xp, yp) (8.4.2).
fn mpm_list(&self, xp: i32, yp: i32) -> [u32; 3] {
let cand_a = if self.pic.zavail(xp, yp, xp - 1, yp, self.slice_addr) {
let b = self.pic.blk_at(xp - 1, yp);
if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 }
} else {
1
};
let cand_b = if self.pic.zavail(xp, yp, xp, yp - 1, self.slice_addr) && yp > ((yp >> self.sps.log2_ctb) << self.sps.log2_ctb) {
let b = self.pic.blk_at(xp, yp - 1);
if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 }
} else {
1
};
if cand_a == cand_b {
if cand_a < 2 { [0, 1, 26] } else { [cand_a, 2 + ((cand_a + 29) % 32), 2 + ((cand_a - 2 + 1) % 32)] }
} else {
let c = if cand_a != 0 && cand_b != 0 {
0
} else if cand_a != 1 && cand_b != 1 {
1
} else {
26
};
[cand_a, cand_b, c]
}
}
fn parse_intra_modes(&mut self, x0: i32, y0: i32, n: i32, part: PartMode) -> Result<()> {
let nb = if part == PartMode::PNxN { 2 } else { 1 };
let pb = n / nb;
@@ -838,31 +878,7 @@ impl<'a> SliceDecoder<'a> {
for i in 0..nb {
let k = (j * nb + i) as usize;
let (xp, yp) = (x0 + i * pb, y0 + j * pb);
// candidate modes (8.4.2)
let cand_a = if self.pic.zavail(xp, yp, xp - 1, yp, self.slice_addr) {
let b = self.pic.blk_at(xp - 1, yp);
if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 }
} else {
1
};
let cand_b = if self.pic.zavail(xp, yp, xp, yp - 1, self.slice_addr) && yp > ((yp >> self.sps.log2_ctb) << self.sps.log2_ctb) {
let b = self.pic.blk_at(xp, yp - 1);
if b.flags & F_INTRA != 0 { b.ipm as u32 } else { 1 }
} else {
1
};
let mut list = if cand_a == cand_b {
if cand_a < 2 { [0, 1, 26] } else { [cand_a, 2 + ((cand_a + 29) % 32), 2 + ((cand_a - 2 + 1) % 32)] }
} else {
let c = if cand_a != 0 && cand_b != 0 {
0
} else if cand_a != 1 && cand_b != 1 {
1
} else {
26
};
[cand_a, cand_b, c]
};
let mut list = self.mpm_list(xp, yp);
let mode = if prev[k] {
let idx = if self.bypass() == 0 {
0
@@ -898,7 +914,7 @@ impl<'a> SliceDecoder<'a> {
Ok(())
}
fn transform_tree(&mut self, x0: i32, y0: i32, xb: i32, yb: i32, log2: u32, depth: u32, blk_idx: u32, parent_cbf_c: [bool; 2]) -> Result<()> {
fn transform_tree(&mut self, (x0, y0): (i32, i32), base: (i32, i32), log2: u32, depth: u32, blk_idx: u32, parent_cbf_c: [bool; 2]) -> Result<()> {
let split =
if log2 <= self.sps.log2_max_tb && log2 > self.sps.log2_min_tb && depth < self.max_trafo_depth && !(self.intra_split && depth == 0) {
self.dd(SPLIT_TRANSFORM + (5 - log2 as usize)) == 1
@@ -923,10 +939,10 @@ impl<'a> SliceDecoder<'a> {
}
if split {
let h = 1 << (log2 - 1);
self.transform_tree(x0, y0, x0, y0, log2 - 1, depth + 1, 0, cbf_c)?;
self.transform_tree(x0 + h, y0, x0, y0, log2 - 1, depth + 1, 1, cbf_c)?;
self.transform_tree(x0, y0 + h, x0, y0, log2 - 1, depth + 1, 2, cbf_c)?;
self.transform_tree(x0 + h, y0 + h, x0, y0, log2 - 1, depth + 1, 3, cbf_c)?;
self.transform_tree((x0, y0), (x0, y0), log2 - 1, depth + 1, 0, cbf_c)?;
self.transform_tree((x0 + h, y0), (x0, y0), log2 - 1, depth + 1, 1, cbf_c)?;
self.transform_tree((x0, y0 + h), (x0, y0), log2 - 1, depth + 1, 2, cbf_c)?;
self.transform_tree((x0 + h, y0 + h), (x0, y0), log2 - 1, depth + 1, 3, cbf_c)?;
return Ok(());
}
let cbf_luma =
@@ -935,36 +951,50 @@ impl<'a> SliceDecoder<'a> {
if depth > 0 {
self.mark_edges(x0, y0, n, n, E_TU_V, E_TU_H);
}
self.transform_unit(x0, y0, xb, yb, log2, blk_idx, cbf_luma, cbf_c)
self.transform_unit((x0, y0), base, log2, blk_idx, cbf_luma, cbf_c)
}
fn transform_unit(&mut self, x0: i32, y0: i32, xb: i32, yb: i32, log2: u32, blk_idx: u32, cbf_luma: bool, cbf_c: [bool; 2]) -> Result<()> {
/// cu_qp_delta_abs / sign and QP update (7.3.8.14).
fn parse_cu_qp_delta(&mut self) -> Result<()> {
// cu_qp_delta_abs: prefix TU(5) with ctx 0,1,1,1,1, suffix EG0
let mut v = 0;
while v < 5 && self.dd(CU_QP_DELTA_ABS + if v == 0 { 0 } else { 1 }) == 1 {
v += 1;
}
if v == 5 {
let mut k = 0;
while self.bypass() == 1 {
v += 1 << k;
k += 1;
ensure!(k < 32, "invalid cu_qp_delta_abs");
}
v += self.bypass_bits(k) as i32;
}
if v != 0 && self.bypass() == 1 {
v = -v;
}
let off = 6 * (self.pic.bd_y as i32 - 8);
ensure!(v >= -(26 + off / 2) && v <= 25 + off / 2, "CuQpDeltaVal out of range");
self.is_cu_qp_delta_coded = true;
self.cu_qp_delta = v;
self.update_qp();
Ok(())
}
fn transform_unit(
&mut self,
(x0, y0): (i32, i32),
(xb, yb): (i32, i32),
log2: u32,
blk_idx: u32,
cbf_luma: bool,
cbf_c: [bool; 2],
) -> Result<()> {
let n = 1i32 << log2;
let chroma_here = log2 > 2 || blk_idx == 3;
let cbf_chroma = cbf_c[0] || cbf_c[1];
if (cbf_luma || cbf_chroma) && self.pps.cu_qp_delta_enabled && !self.is_cu_qp_delta_coded {
// cu_qp_delta_abs: prefix TU(5) with ctx 0,1,1,1,1, suffix EG0
let mut v = 0;
while v < 5 && self.dd(CU_QP_DELTA_ABS + if v == 0 { 0 } else { 1 }) == 1 {
v += 1;
}
if v == 5 {
let mut k = 0;
while self.bypass() == 1 {
v += 1 << k;
k += 1;
ensure!(k < 32, "invalid cu_qp_delta_abs");
}
v += self.bypass_bits(k) as i32;
}
if v != 0 && self.bypass() == 1 {
v = -v;
}
let off = 6 * (self.pic.bd_y as i32 - 8);
ensure!(v >= -(26 + off / 2) && v <= 25 + off / 2, "CuQpDeltaVal out of range");
self.is_cu_qp_delta_coded = true;
self.cu_qp_delta = v;
self.update_qp();
self.parse_cu_qp_delta()?;
}
// luma
if self.cu_intra {
@@ -1264,7 +1294,7 @@ impl<'a> SliceDecoder<'a> {
max_y = max_y.max(yc);
}
}
self.reconstruct(x0, y0, log2, c, qp, ts, max_x, max_y)
self.reconstruct((x0, y0), log2, c, qp, ts, (max_x, max_y))
}
fn coeff_abs_level_remaining(&mut self, rice: u32) -> Result<i32> {
@@ -1283,7 +1313,7 @@ impl<'a> SliceDecoder<'a> {
}
/// Scaling, transform and addition to the prediction.
fn reconstruct(&mut self, x0: i32, y0: i32, log2: u32, c: usize, qp: i32, ts: bool, max_x: usize, max_y: usize) -> Result<()> {
fn reconstruct(&mut self, (x0, y0): (i32, i32), log2: u32, c: usize, qp: i32, ts: bool, (max_x, max_y): (usize, usize)) -> Result<()> {
let n = 1usize << log2;
let bd = if c == 0 { self.pic.bd_y } else { self.pic.bd_c };
let coeffs = &mut self.coeffs[..n * n];
@@ -1335,96 +1365,107 @@ impl<'a> SliceDecoder<'a> {
}
/// prediction_unit(): parse, derive motion, store it and predict. Returns merge_flag.
fn prediction_unit(
&mut self,
xc: i32,
yc: i32,
ncb: i32,
xp: i32,
yp: i32,
w: i32,
h: i32,
part_idx: usize,
part: PartMode,
skip: bool,
) -> Result<bool> {
fn prediction_unit(&mut self, g: PbGeom, part: PartMode, skip: bool) -> Result<bool> {
let merge = if skip { true } else { self.dd(MERGE_FLAG) == 1 };
let mvf = if merge {
let idx = if self.sh.max_num_merge_cand > 1 {
let mut i = 0;
if self.dd(MERGE_IDX) == 1 {
i = 1;
while i < self.sh.max_num_merge_cand - 1 && self.bypass() == 1 {
i += 1;
}
}
i
} else {
0
};
self.derive_merge(xc, yc, ncb, xp, yp, w, h, part_idx, part, idx as usize)
let idx = self.merge_idx();
self.derive_merge(g, part, idx as usize)
} else {
let pred_idc = if self.sh.is_b() {
if w + h != 12 {
let depth = self.pic.blk_at(xc, yc).depth as usize;
if self.dd(INTER_PRED_IDC + depth) == 1 { 2 } else { self.dd(INTER_PRED_IDC + 4) }
} else {
self.dd(INTER_PRED_IDC + 4)
}
} else {
0
};
let mut ref_idx = [-1i8; 2];
let mut mvd = [[0i32; 2]; 2];
let mut mvp_flag = [0u32; 2];
for l in 0..2 {
let used = if l == 0 { pred_idc != 1 } else { pred_idc != 0 };
if !used {
continue;
}
let nref = self.sh.num_ref_idx[l];
let mut ri = 0;
if nref > 1 {
while ri < nref - 1 {
let bin = if ri < 2 { self.dd(REF_IDX + ri as usize) } else { self.bypass() };
if bin == 0 {
break;
}
ri += 1;
}
}
ref_idx[l] = ri as i8;
if l == 1 && self.sh.mvd_l1_zero && pred_idc == 2 {
mvd[1] = [0, 0];
} else {
mvd[l] = self.mvd_coding()?;
}
mvp_flag[l] = self.dd(MVP_FLAG);
}
let mut f = MvField { mv: [[0; 2]; 2], ref_idx };
for l in 0..2 {
if ref_idx[l] < 0 {
continue;
}
let mvp = self.derive_amvp(xc, yc, ncb, xp, yp, w, h, part_idx, l, ref_idx[l] as usize, mvp_flag[l] as usize);
// mvLX = (mvp + mvd) wrapped to 16 bits (8-94)
f.mv[l] = [(mvp[0] as i32 + mvd[l][0]) as i16, (mvp[1] as i32 + mvd[l][1]) as i16];
}
f
self.amvp_motion(g)?
};
for l in 0..2 {
if mvf.ref_idx[l] >= 0 {
ensure!((mvf.ref_idx[l] as usize) < self.refs[l].len(), "reference index out of range");
}
}
// store motion
self.store_motion(g.xp, g.yp, g.w, g.h, mvf);
self.motion_compensate(g.xp, g.yp, g.w as usize, g.h as usize, &mvf);
Ok(merge)
}
/// merge_idx(): the merge candidate index, truncated unary (0 when there is one candidate).
fn merge_idx(&mut self) -> u32 {
if self.sh.max_num_merge_cand <= 1 {
return 0;
}
let mut i = 0;
if self.dd(MERGE_IDX) == 1 {
i = 1;
while i < self.sh.max_num_merge_cand - 1 && self.bypass() == 1 {
i += 1;
}
}
i
}
/// inter_pred_idc: 0 = list 0, 1 = list 1, 2 = both.
fn inter_pred_idc(&mut self, xc: i32, yc: i32, w: i32, h: i32) -> u32 {
if !self.sh.is_b() {
return 0;
}
if w + h != 12 {
let depth = self.pic.blk_at(xc, yc).depth as usize;
if self.dd(INTER_PRED_IDC + depth) == 1 { 2 } else { self.dd(INTER_PRED_IDC + 4) }
} else {
self.dd(INTER_PRED_IDC + 4)
}
}
/// ref_idx_lX: truncated unary, the first two bins context-coded.
fn ref_idx(&mut self, l: usize) -> u32 {
let nref = self.sh.num_ref_idx[l];
let mut ri = 0;
if nref > 1 {
while ri < nref - 1 {
let bin = if ri < 2 { self.dd(REF_IDX + ri as usize) } else { self.bypass() };
if bin == 0 {
break;
}
ri += 1;
}
}
ri
}
/// The AMVP branch: parse each used list's reference index, mvd and predictor flag, then add
/// every mvd to its predictor.
fn amvp_motion(&mut self, g: PbGeom) -> Result<MvField> {
let pred_idc = self.inter_pred_idc(g.xc, g.yc, g.w, g.h);
let mut ref_idx = [-1i8; 2];
let mut mvd = [[0i32; 2]; 2];
let mut mvp_flag = [0u32; 2];
for l in 0..2 {
let used = if l == 0 { pred_idc != 1 } else { pred_idc != 0 };
if !used {
continue;
}
ref_idx[l] = self.ref_idx(l) as i8;
if l == 1 && self.sh.mvd_l1_zero && pred_idc == 2 {
mvd[1] = [0, 0];
} else {
mvd[l] = self.mvd_coding()?;
}
mvp_flag[l] = self.dd(MVP_FLAG);
}
let mut f = MvField { mv: [[0; 2]; 2], ref_idx };
for l in 0..2 {
if ref_idx[l] < 0 {
continue;
}
let mvp = self.derive_amvp(g, l, ref_idx[l] as usize, mvp_flag[l] as usize);
// mvLX = (mvp + mvd) wrapped to 16 bits (8-94)
f.mv[l] = [(mvp[0] as i32 + mvd[l][0]) as i16, (mvp[1] as i32 + mvd[l][1]) as i16];
}
Ok(f)
}
/// Writes a prediction block's motion into the picture's 4x4 motion field.
fn store_motion(&mut self, xp: i32, yp: i32, w: i32, h: i32, mvf: MvField) {
let w4 = self.pic.w4;
for by in (yp >> 2)..((yp + h) >> 2) {
let o = by as usize * w4;
self.pic.mvf[o + (xp >> 2) as usize..o + ((xp + w) >> 2) as usize].fill(mvf);
}
self.motion_compensate(xp, yp, w as usize, h as usize, &mvf);
Ok(merge)
}
fn mvd_coding(&mut self) -> Result<[i32; 2]> {
@@ -1504,11 +1545,11 @@ impl<'a> SliceDecoder<'a> {
if bi {
let (w0, o0) = get(0);
let (w1, o1) = get(1);
inter::put_weighted_bi(&self.pred0, &self.pred1, bw, bh, bd, log2wd, w0, w1, o0, o1, dst, stride);
inter::put_weighted_bi(&self.pred0, &self.pred1, (bw, bh), bd, (log2wd, w0, w1, o0, o1), dst, stride);
} else {
let l = if lists[0] { 0 } else { 1 };
let (wt, o) = get(l);
inter::put_weighted_uni(&self.pred0, bw, bh, bd, log2wd, wt, o, dst, stride);
inter::put_weighted_uni(&self.pred0, (bw, bh), bd, (log2wd, wt, o), dst, stride);
}
}
}
+1 -1
View File
@@ -22,4 +22,4 @@ log = { workspace = true }
image = { workspace = true }
# Audio decoding for the formats we have no decoder of our own for (MPL-2.0, used unmodified,
# as in FilmCraft).
symphonia = { version = "0.5.5", default-features = false, features = ["aac", "adpcm", "alac", "flac", "mp1", "mp2", "mp3", "pcm", "vorbis", "wav", "aiff", "caf", "ogg", "mkv", "isomp4"] }
symphonia = { version = "0.6.1", default-features = false, features = ["aac", "adpcm", "alac", "flac", "mp1", "mp2", "mp3", "pcm", "vorbis", "wav", "aiff", "caf", "ogg", "mkv", "isomp4"] }
+18 -2
View File
@@ -24,6 +24,8 @@ pub struct Pcm {
pub samples: Vec<f32>,
}
const MAX_PCM_SAMPLES: usize = 64 * 1024 * 1024;
impl Pcm {
/// Sample frames (samples per channel).
pub fn frames(&self) -> usize {
@@ -47,8 +49,10 @@ impl Pcm {
fn push_planar(planes: &[Vec<f32>], out: &mut Vec<f32>) {
let n = planes.iter().map(Vec::len).min().unwrap_or(0);
out.reserve(n * planes.len());
for i in 0..n {
let remaining = MAX_PCM_SAMPLES.saturating_sub(out.len());
let to_push = n.min(remaining / planes.len().max(1));
out.reserve(to_push * planes.len());
for i in 0..to_push {
for p in planes {
out.push(p[i]);
}
@@ -96,7 +100,13 @@ fn decode_ogg_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
let timing = deckcraft_ogg::OpusTiming::of(stream, dec.pre_skip() as u32).map_err(|e| MediaError::Corrupt(e.to_string()))?;
let mut out = Vec::new();
for i in 0..stream.packets.len() {
if MAX_PCM_SAMPLES.saturating_sub(out.len()) < channels.max(1) as usize {
break;
}
let Ok(p) = file.read_packet(src, s, i) else { continue };
if p.is_empty() {
continue;
}
match dec.decode(Some(&p)) {
Ok(planes) => Pcm::push_planar(&planes, &mut out),
Err(e) => log::debug!("opus packet {i}: {e}"),
@@ -126,7 +136,13 @@ fn decode_mkv_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
let mut out = Vec::new();
let n = file.tracks.get(ti).map(|t| t.samples.len()).unwrap_or(0);
for i in 0..n {
if MAX_PCM_SAMPLES.saturating_sub(out.len()) < channels.max(1) as usize {
break;
}
let Ok(p) = file.read_sample(src, ti, i) else { continue };
if p.is_empty() {
continue;
}
match dec.decode(Some(&p)) {
Ok(planes) => Pcm::push_planar(&planes, &mut out),
Err(e) => log::debug!("opus packet {i}: {e}"),
Binary file not shown.
+1
View File
@@ -37,4 +37,5 @@ $FF $VID $TONE44 -c:v libx264 -pix_fmt yuv420p -g 5 -bf 2 -c:a aac -b:a 48k -sho
$FF $VID -c:v libx264 -pix_fmt yuv420p -g 5 -bf 0 $META clip-h264.mov
$FF $VID -c:v libx265 -pix_fmt yuv420p -x265-params log-level=error:keyint=5 -tag:v hvc1 $META clip-hevc.mp4
$FF $VID $TONE -c:v libvpx-vp9 -pix_fmt yuv420p -g 5 -b:v 100k -c:a libopus -b:a 32k -shortest $META clip-vp9.webm
$FF $VID $TONE44 -c:v libvpx -pix_fmt yuv420p -g 5 -b:v 100k -deadline realtime -c:a vorbis -strict -2 -ac 2 -b:a 48k -shortest $META clip-vp8.webm
$FF -f rawvideo -pix_fmt rgb24 -s 64x64 -r 10 -i "$TMP_DIR/deckcraft-fixture-64x64.rgb" -c:v libsvtav1 -pix_fmt yuv420p -g 5 -svtav1-params loglevel=0 $META clip-av1.mkv
+29 -1
View File
@@ -1,4 +1,8 @@
use super::*;
use super::{
ColorRef, Fill, LayoutType, Paragraph, PhType, Placeholder, Presentation, Run, RunProps, SchemeSlot, Shape, ShapeId, ShapeKind, ShapeStyle,
Slide, Table, TextBody, Theme, Xfrm, defaults, ink_pressure_is_legacy, ink_sample_width, parent_of, resolve, sibling_list_mut, style, text,
theme, walk,
};
use crate::resolve::Ctx;
#[test]
@@ -294,3 +298,27 @@ fn table_text_takes_the_masters_other_style_and_shapes_the_default_text_style()
assert_eq!(pt(&Shape { text: Some(body.clone()), ..Default::default() }), Some(26.0), "a shape");
assert_eq!(pt(&Shape { text: Some(body), text_box: true, ..Default::default() }), Some(26.0), "a text box");
}
#[test]
fn only_web_and_mail_links_are_safe_to_open() {
use crate::text::safe_url;
for url in ["https://example.com", " HTTP://example.com/x", "mailto:someone@example.com"] {
assert!(safe_url(url), "{url}");
}
let refused = [
"https://",
"mailto:",
"file:///C:/notes.pdf",
"notes.pdf",
"javascript:alert(1)",
"ms-msdt:/id PCWDiagnostic",
"ldap://attacker.example/x",
"//attacker.example/share",
"\\\\attacker.example\\share",
"smb://attacker.example/share",
"search-ms:query=x",
];
for url in refused {
assert!(!safe_url(url), "{url}");
}
}
+13
View File
@@ -328,6 +328,19 @@ pub enum Action {
PlayMedia,
}
/// Can a link from a (possibly untrusted) deck be opened outside it, by the slide show or by
/// whoever opens an exported file? Only web pages and mail: `file:`, `javascript:`, relative paths
/// and other schemes (OS protocol handlers, network shares) are not.
pub fn safe_url(url: &str) -> bool {
let u = url.trim();
let Some((scheme, rest)) = u.split_once(':') else { return false };
match scheme.to_ascii_lowercase().as_str() {
"http" | "https" => rest.starts_with("//") && rest.len() > 2,
"mailto" => !rest.is_empty(),
_ => false,
}
}
fn custom_show_returns() -> bool {
true
}
+57 -10
View File
@@ -241,26 +241,28 @@ fn linreg(xs: &[f64], ys: &[f64], intercept: Option<f64>) -> Option<(f64, f64)>
sxx += x * x;
sxy += x * (y - b);
}
if sxx.abs() < 1e-18 {
if sxx <= 0.0 || !sxx.is_finite() {
return None;
}
let m = sxy / sxx;
return (m.is_finite() && b.is_finite()).then_some((b, m));
}
let n = xs.len() as f64;
let (mut sx, mut sy, mut sxx, mut sxy) = (0.0, 0.0, 0.0, 0.0);
let mx = xs.iter().sum::<f64>() / n;
let my = ys.iter().sum::<f64>() / n;
let (mut sxx, mut sxy) = (0.0, 0.0);
for (x, y) in xs.iter().zip(ys) {
sx += x;
sy += y;
sxx += x * x;
sxy += x * y;
let dx = x - mx;
sxx += dx * dx;
sxy += dx * (y - my);
}
let den = n * sxx - sx * sx;
if den.abs() < 1e-18 {
// Constant x with a large offset leaves rounding residue in `mx`, so compare against the
// rounding scale of the mean instead of zero.
if sxx <= 4.0 * n * (f64::EPSILON * mx.abs()).powi(2) || !sxx.is_finite() {
return None;
}
let m = (n * sxy - sx * sy) / den;
let b = (sy - m * sx) / n;
let m = sxy / sxx;
let b = my - m * mx;
(m.is_finite() && b.is_finite()).then_some((b, m))
}
@@ -561,6 +563,51 @@ mod tests {
Trendline { kind, ..Default::default() }
}
#[test]
fn linear_fits_tiny_x_values() {
let xs = [1e-10, 2e-10, 3e-10, 4e-10, 5e-10];
let ys = [1.0, 2.0, 3.0, 4.0, 5.0];
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of tiny x values");
assert!((fit.y(3e-10).unwrap() - 3.0).abs() < 1e-6);
}
#[test]
fn linear_fits_offset_but_narrow_x_range() {
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
let ys = [1.0, 2.0, 3.0, 4.0, 5.0];
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of offset x values");
assert!((fit.y(45000.02).unwrap() - 3.0).abs() < 1e-4);
let xs = [1e7, 1e7 + 1.0, 1e7 + 2.0, 1e7 + 3.0, 1e7 + 4.0];
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit of 1e7 offset");
assert!((fit.y(1e7 + 2.0).unwrap() - 3.0).abs() < 1e-4);
}
#[test]
fn linear_fixed_intercept_on_offset_narrow_x_range() {
let xs = [1e7, 1e7 + 1.0, 1e7 + 2.0, 1e7 + 3.0, 1e7 + 4.0];
let ys: Vec<f64> = xs.iter().map(|x| 2.0 * x).collect();
let mut forced = line(TrendlineType::Linear);
forced.intercept = Some(0.0);
let fit = forced.fit(&xs, &ys).expect("fixed-intercept fit");
assert!((fit.y(1e7 + 2.0).unwrap() / (2.0 * (1e7 + 2.0)) - 1.0).abs() < 1e-9);
}
#[test]
fn linear_fixed_intercept_rejects_overflowing_x() {
let xs = [1e160, 2e160, 3e160];
let ys = [1.0, 2.0, 3.0];
let mut forced = line(TrendlineType::Linear);
forced.intercept = Some(0.0);
assert!(forced.fit(&xs, &ys).is_none());
}
#[test]
fn linear_rejects_constant_x_with_large_offset() {
let xs = [45000.1; 5];
let ys = [1.0, 2.0, 3.0, 4.0, 5.0];
assert!(line(TrendlineType::Linear).fit(&xs, &ys).is_none());
}
#[test]
fn linear_recovers_slope_and_a_fixed_intercept() {
let xs = [1.0, 2.0, 3.0, 4.0];
+41 -14
View File
@@ -212,10 +212,11 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
let mut pos = 0u64;
let mut found_any = false;
let mut hdr = [0u8; 27 + 255];
let mut scanner = CaptureScanner::new();
while pos + 27 <= len {
src.read_at(pos, &mut hdr[..27])?;
if &hdr[..4] != b"OggS" || hdr[4] != 0 {
match find_capture(src, pos + 1, len)? {
match scanner.find(src, pos + 1, len)? {
Some(p) => {
if found_any {
warnings.push(format!("lost sync at byte {pos}; resumed at {p}"));
@@ -336,21 +337,47 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
Ok(OggFile { streams, warnings })
}
fn find_capture(src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result<Option<u64>> {
let mut at = from;
while at + 4 <= len {
let n = ((len - at) as usize).min(1 << 20);
let mut buf = vec![0u8; n];
src.read_at(at, &mut buf)?;
if let Some(i) = buf.windows(4).position(|w| w == b"OggS") {
return Ok(Some(at + i as u64));
struct CaptureScanner {
buf: Vec<u8>,
buf_start: u64,
budget: usize,
}
impl CaptureScanner {
fn new() -> Self {
Self { buf: Vec::new(), buf_start: 0, budget: 32 * 1024 * 1024 }
}
fn find(&mut self, src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result<Option<u64>> {
let mut at = from;
while at + 4 <= len {
let buf_end = self.buf_start + self.buf.len() as u64;
if at >= self.buf_start && at + 4 <= buf_end {
let off = (at - self.buf_start) as usize;
if let Some(i) = self.buf[off..].windows(4).position(|w| w == b"OggS") {
return Ok(Some(at + i as u64));
}
at = buf_end.saturating_sub(3);
continue;
}
if self.budget == 0 {
return Ok(None);
}
let n = ((len - at) as usize).min(64 * 1024).min(self.budget);
if n < 4 {
break;
}
self.buf.resize(n, 0);
src.read_at(at, &mut self.buf)?;
self.buf_start = at;
self.budget = self.budget.saturating_sub(n);
if let Some(i) = self.buf.windows(4).position(|w| w == b"OggS") {
return Ok(Some(at + i as u64));
}
at += (n - 3) as u64;
}
if n < 4 {
break;
}
at += (n - 3) as u64;
Ok(None)
}
Ok(None)
}
fn read_parts(src: &(impl ByteSource + ?Sized), parts: &[(u64, u32)]) -> Result<Vec<u8>> {
+1 -1
View File
@@ -1,6 +1,6 @@
//! Synthetic Ogg streams: a small page writer (RFC 3533) and Opus-like packets.
use super::*;
use super::{Codec, Error, OpusTiming, Packet, Stream, crc32, open, opus_packet_samples, sniff};
/// Write one page holding `segments` (lacing already computed by `lace`).
fn page(out: &mut Vec<u8>, flags: u8, granule: i64, serial: u32, seq: u32, lacing: &[u8], body: &[u8]) {
+1 -1
View File
@@ -20,7 +20,7 @@ thiserror = { workspace = true }
log = { workspace = true }
[dev-dependencies]
hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] }
hayro-syntax = { version = "=0.8.0", default-features = false, features = ["std"] }
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3"
+4 -2
View File
@@ -17,7 +17,7 @@ use craft_pdf::write::{self, Metadata, Writer, backend as krilla};
use deckcraft_fonts::FontDb;
use deckcraft_model::Presentation;
use deckcraft_model::text::Action as ClickAction;
use deckcraft_model::text::{Action as ClickAction, safe_url};
use deckcraft_render::{PlacedText, RenderOpts};
use krilla::action::{Action, LinkAction};
use krilla::annotation::{Annotation, LinkAnnotation, Target};
@@ -565,7 +565,9 @@ impl Exporter<'_> {
slide.and_then(|s| page_of.get(&s)).map(|p| Target::Destination(XyzDestination::new(*p, Point::from_xy(0.0, 0.0)).into()))
};
let target = match &l.link.action {
ClickAction::Url { url } => Some(Target::Action(Action::Link(LinkAction::new(url.clone())))),
// Only the links the slide show opens (web and mail): a reader of the file gets the rest
// as plain text.
ClickAction::Url { url } if safe_url(url) => Some(Target::Action(Action::Link(LinkAction::new(url.clone())))),
ClickAction::Slide { slide } => goto(pres.slides.iter().position(|x| x.id == *slide)),
ClickAction::NextSlide => goto(Some(i + 1)),
ClickAction::PreviousSlide => goto(i.checked_sub(1)),
+69 -1
View File
@@ -1,6 +1,15 @@
use std::sync::Arc;
use super::*;
use std::collections::HashMap;
use deckcraft_fonts::FontDb;
use deckcraft_render::RenderOpts;
use kurbo::Rect;
use super::{
MAX_RASTER_PIXELS, PAPERS, PageLayout, PdfError, PdfOptions, Presentation, export, glyph_text_ranges, handout_boxes, master_text_pieces,
master_texts, notes_frames, notes_slide_box, paper_size, raster_scale,
};
use deckcraft_model::text::TextBody;
fn deck(n: usize) -> Presentation {
@@ -130,6 +139,65 @@ fn text_layer_links_and_bookmarks_survive_serialization() {
}
}
/// Links from an untrusted deck: only web and mail links become PDF links. Scripts, `data:`,
/// local and relative files, OS protocol handlers and network shares (a UNC path leaks the user's
/// NTLM credentials on Windows) do not.
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn unsafe_url_links_are_not_exported() {
use deckcraft_model::text::{Action, Hyperlink};
use deckcraft_model::{Shape, ShapeId, Xfrm};
use hayro_syntax::object::{Array, Dict, String as PdfString};
let safe = ["https://example.com/deck", "mailto:someone@example.com"];
let refused = [
"file:///C:/talks/notes.pdf",
"notes.pdf",
"ms-msdt:/id PCWDiagnostic /skip force",
"ms-word:ofe|u|\\\\attacker.example\\share\\x.docx",
"ldap://attacker.example/x",
"file:///%5C%5Cattacker.example/share/x",
"javascript:alert(1)",
"\t JavaScript:alert(1)",
"java\nscript:alert(1)",
"vbscript:MsgBox(1)",
"data:text/html,<script>alert(1)</script>",
"\\\\attacker.example\\share\\x",
"//attacker.example/share/x",
"file://attacker.example/share/x",
"file:////attacker.example/share/x",
"smb://attacker.example/share/x",
"search-ms:query=x&crumb=location:\\\\attacker.example\\share",
"search:query=x&crumb=location:\\\\attacker.example\\share",
];
let mut p = deck(1);
let slide = Arc::make_mut(&mut p.slides[0]);
slide.shapes = (0..)
.zip(safe.iter().chain(&refused))
.map(|(i, url)| Shape {
id: ShapeId(9000 + i),
xfrm: Some(Xfrm::new(20.0, 20.0 + 32.0 * f64::from(i), 400.0, 26.0)),
click: Some(Hyperlink {
action: Action::Url { url: url.to_string() },
tooltip: String::new(),
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
}),
..Default::default()
})
.collect();
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let annotations = pdf.pages()[0].raw().get::<Array<'_>>(b"Annots").unwrap();
let uris: Vec<String> = annotations
.iter::<Dict<'_>>()
.filter_map(|a| a.get::<Dict<'_>>(b"A")?.get::<PdfString<'_>>(b"URI"))
.map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned())
.collect();
assert_eq!(uris, safe);
}
/// Build a small damaged derivative of our openly licensed last-resort font entirely in
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
/// embedding. No font bytes or vendor assets are stored as test fixtures.
+18 -2
View File
@@ -279,7 +279,11 @@ fn percent_decode(s: &str) -> String {
while i < b.len() {
let c = b.get(i).copied().unwrap_or(0);
if c == b'%'
&& let Some(h) = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())
&& let Some(h) = b
.get(i + 1..i + 3)
.filter(|h| h.iter().all(u8::is_ascii_hexdigit))
.and_then(|h| std::str::from_utf8(h).ok())
.and_then(|h| u8::from_str_radix(h, 16).ok())
{
out.push(h);
i += 3;
@@ -293,7 +297,7 @@ fn percent_decode(s: &str) -> String {
/// Resolve a relative target against a base directory into a part name.
pub fn resolve(base: &str, target: &str) -> String {
let target = percent_decode(target.split('#').next().unwrap_or(""));
let target = percent_decode(target.split('#').next().unwrap_or("")).replace('\\', "/");
let target = target.as_str();
let mut segs: Vec<&str> = if target.starts_with('/') { vec![] } else { base.split('/').filter(|s| !s.is_empty()).collect() };
for s in target.split('/') {
@@ -464,6 +468,12 @@ mod tests {
assert_eq!(resolve("ppt/slides", "../media/a.png#frag"), "ppt/media/a.png");
}
#[test]
fn resolve_percent_decode_requires_two_hex_digits() {
assert_eq!(resolve("ppt/slides", "../media/a%+5b.png"), "ppt/media/a%+5b.png");
assert_eq!(resolve("ppt/slides", "../media/a%20b.png"), "ppt/media/a b.png");
}
fn zip_of(entries: &[(&str, usize)]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
@@ -497,6 +507,12 @@ mod tests {
assert!(p.xml_bytes("missing.xml").is_none());
}
#[test]
fn resolve_backslash_target() {
assert_eq!(resolve("ppt/slides", "..\\media\\image1.png"), "ppt/media/image1.png");
assert_eq!(resolve("ppt/slides", "\\ppt\\media\\a.png"), "ppt/media/a.png");
}
#[test]
fn path_resolution() {
assert_eq!(resolve("ppt/slides", "../media/image1.png"), "ppt/media/image1.png");
+11
View File
@@ -447,6 +447,17 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
pics.into_iter().find_map(|b| dml::media_ref(imp, part, b))
}
.or_else(|| gd.and_then(|g| g.find("oleObj")).and_then(|o| vml_preview(imp, part, o.attr("spid")?)));
// An embedded object's fallback picture (`p:oleObj/p:pic`) can carry a fill and an outline,
// which PowerPoint draws behind and around the preview (old equation objects on coloured
// boxes). The frame's own position stays.
if let Some(pr) = gd.and_then(|g| g.find("oleObj")).and_then(|o| o.find("pic")).and_then(|p| p.child("spPr")) {
if s.fill.is_none() {
s.fill = dml::fill_of(imp, part, pr);
}
if s.line.is_none() {
s.line = pr.child("ln").map(|ln| dml::line(imp, part, ln));
}
}
let label = if let Some(o) = gd.and_then(|g| g.find("oleObj")) {
o.attr("progId").or(o.attr("name")).unwrap_or("Object").to_string()
} else if uri.ends_with("/diagram") {
+5 -1
View File
@@ -909,8 +909,12 @@ fn shape_with_id(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, i
w.empty0("p:nvPr");
w.close("p:nvPicPr");
dml::blip_fill(w, x, o, "p:blipFill", fill);
// As for any picture, a colour or pattern fill shows behind the image (an object's preview
// keeps its fallback picture's fill).
let mut s2 = s.clone();
s2.fill = None;
if matches!(s2.fill, Some(Fill::Picture(_) | Fill::Background | Fill::Group)) {
s2.fill = None;
}
sp_pr(w, x, o, &s2, true);
w.close("p:pic");
} else {
+51
View File
@@ -96,3 +96,54 @@ fn an_old_ole_object_shows_its_vml_preview() {
fn an_ole_object_without_a_matching_vml_shape_has_no_preview() {
assert_eq!(object_preview(&ole_deck("_x0000_s1")), None);
}
/// PowerPoint 2010 and later: `p:oleObj` with a fallback `p:pic` inside `mc:AlternateContent`, whose
/// `spPr` can give the object a fill and an outline (old equation objects on coloured boxes).
#[test]
fn an_ole_objects_fallback_picture_gives_it_its_fill_and_outline() {
let pic = |spr: &str| {
format!(
r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="900" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:v="urn:schemas-microsoft-com:vml" Requires="v"><p:oleObj name="Equation" r:id="rIdOle" imgW="1663700" imgH="228600" progId="Equation.3"><p:embed/></p:oleObj></mc:Choice><mc:Fallback><p:oleObj name="Equation" r:id="rIdOle" imgW="1663700" imgH="228600" progId="Equation.3"><p:embed/><p:pic><p:nvPicPr><p:cNvPr id="0" name="Object 3"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rIdImg"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom>{spr}</p:spPr></p:pic></p:oleObj></mc:Fallback></mc:AlternateContent></a:graphicData></a:graphic></p:graphicFrame>"#
)
};
let deck = |frame: String| {
with_parts(&[("ppt/media/eq.png", png()), ("ppt/embeddings/oleObject1.bin", vec![0; 16])], move |name, s| {
if name == "ppt/slides/slide1.xml" {
s.replacen("</p:spTree>", &format!("{frame}</p:spTree>"), 1)
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
s.replacen(
"</Relationships>",
r#"<Relationship Id="rIdImg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/eq.png"/><Relationship Id="rIdOle" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject" Target="../embeddings/oleObject1.bin"/></Relationships>"#,
1,
)
} else {
s
}
})
};
let object = |bytes: Vec<u8>| {
let p = deckcraft_pptx::import(&bytes).expect("import");
let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object").clone();
assert!(matches!(&s.kind, ShapeKind::Opaque { preview: Some(_), label, .. } if label == "Equation.3"), "{:?}", s.kind);
s
};
let filled = object(deck(pic(
r#"<a:solidFill><a:srgbClr val="FF9900"/></a:solidFill><a:ln w="12700"><a:solidFill><a:srgbClr val="0000FF"/></a:solidFill></a:ln>"#,
)));
let orange = deckcraft_model::style::Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0xFF, 0x99, 0)));
assert_eq!(filled.fill, Some(orange.clone()));
assert_eq!(filled.line.as_ref().and_then(|l| l.width), Some(1.0));
// Saved as its preview picture (its kept XML can't be written back), it keeps the fill.
let mut p = deckcraft_pptx::import(&deck(pic(r#"<a:solidFill><a:srgbClr val="FF9900"/></a:solidFill>"#))).expect("import");
for s in &mut std::sync::Arc::make_mut(&mut p.slides[0]).shapes {
if let ShapeKind::Opaque { xml, .. } = &mut s.kind {
xml.clear();
}
}
let back = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reimport");
let saved = back.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object");
assert!(matches!(saved.kind, ShapeKind::Picture { .. }), "{:?}", saved.kind);
assert_eq!(saved.fill, Some(orange));
// Without a fill of its own the object stays unfilled.
assert_eq!(object(deck(pic(""))).fill, None);
}
+37 -10
View File
@@ -170,19 +170,37 @@ pub fn squarify(vals: &[f64], r: Rect) -> Vec<Rect> {
let mut i = 0;
while i < order.len() {
let short = free.width().min(free.height());
let worst = |row: &[usize]| {
let areas: Vec<f64> = row.iter().map(|&k| vals[k] * scale).collect();
let sum: f64 = areas.iter().sum();
let (mx, mn) = (areas.iter().copied().fold(0.0, f64::max), areas.iter().copied().fold(f64::MAX, f64::min));
((short * short * mx) / (sum * sum)).max((sum * sum) / (short * short * mn))
};
if !short.is_finite() || short <= 1e-9 {
break;
}
let s2 = short * short;
let a0 = vals[order[i]] * scale;
let mut cur_sum = a0;
let mut cur_max = a0;
let mut cur_min = a0;
let mut cur_worst = ((s2 * cur_max) / (cur_sum * cur_sum)).max((cur_sum * cur_sum) / (s2 * cur_min));
let mut end = i + 1;
while end < order.len() && worst(&order[i..=end]) <= worst(&order[i..end]) {
end += 1;
while end < order.len() {
let next_a = vals[order[end]] * scale;
let cand_sum = cur_sum + next_a;
let cand_max = cur_max.max(next_a);
let cand_min = cur_min.min(next_a);
let cand_worst = ((s2 * cand_max) / (cand_sum * cand_sum)).max((cand_sum * cand_sum) / (s2 * cand_min));
if cand_worst.is_finite() && cand_worst <= cur_worst {
cur_sum = cand_sum;
cur_max = cand_max;
cur_min = cand_min;
cur_worst = cand_worst;
end += 1;
} else {
break;
}
}
let row = &order[i..end];
let sum: f64 = row.iter().map(|&k| vals[k] * scale).sum();
let thick = sum / short;
let thick = cur_sum / short;
if !thick.is_finite() || thick <= 0.0 {
break;
}
let mut at = 0.0;
for &k in row {
let len = vals[k] * scale / thick;
@@ -570,4 +588,13 @@ mod tests {
assert_eq!(c.waterfall_bars(), [(0.0, 100.0), (100.0, 140.0), (140.0, 115.0), (0.0, 115.0)]);
assert_eq!(c.value_range(), (0.0, 140.0));
}
#[test]
fn squarify_handles_skewed_and_exhausted_rect() {
let vals = vec![1e12, 1e-6, 1e-6, 1e-6];
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
let tiles = squarify(&vals, r);
assert_eq!(tiles.len(), vals.len());
assert!(tiles[0].area() > 0.0);
}
}
+12 -2
View File
@@ -47,6 +47,7 @@ const GIF_SIDE: u32 = 1024;
const GIF_BUDGET: usize = 64 << 20;
const GIF_MAX_FRAMES: usize = 1000;
const GIF_MAX_CANVAS: u64 = 4096 * 4096;
const GIF_MAX_TOTAL_CANVAS_PIXELS: u64 = 256 * 1024 * 1024;
const GIF_CACHE_BUDGET: usize = 192 << 20;
/// The frames of an animated GIF and when each one ends, in seconds from the start of a loop.
@@ -394,13 +395,22 @@ fn decode_anim(bytes: &[u8], adj: &PictureAdjust) -> Option<Anim> {
let k = (GIF_SIDE as f64 / w.max(h) as f64).min(1.0);
let (nw, nh) = (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1));
let size = nw as usize * nh as usize * 4;
let canvas_pixels = (w as u64) * (h as u64);
let mut total_canvas_pixels: u64 = 0;
let (mut frames, mut ends, mut t) = (Vec::new(), Vec::new(), 0.0);
for f in dec.into_frames() {
let Ok(f) = f else { break };
let mut iter = dec.into_frames();
loop {
if frames.len() >= GIF_MAX_FRAMES || (frames.len() + 1) * size > GIF_BUDGET {
log::info!("animated GIF: keeping its first {} frames", frames.len());
break;
}
if total_canvas_pixels.saturating_add(canvas_pixels) > GIF_MAX_TOTAL_CANVAS_PIXELS {
log::info!("animated GIF: canvas pixel limit reached");
break;
}
let Some(f) = iter.next() else { break };
let Ok(f) = f else { break };
total_canvas_pixels = total_canvas_pixels.saturating_add(canvas_pixels);
let (n, d) = f.delay().numer_denom_ms();
let ms = if d == 0 { 0.0 } else { n as f64 / d as f64 };
// Like browsers: frames of 10 ms or less show for 100 ms.
+116 -33
View File
@@ -321,6 +321,13 @@ pub struct Renderer {
group_fill: Option<(Fill, Affine, Rect)>,
/// Their aligned outlines, kept from frame to frame.
morphs: morph_path::Cache,
/// Whether we are currently drawing an outer-shadow silhouette pass for an ancestor group.
in_shadow_pass: bool,
/// Cumulative shape traversal counter to prevent nested group amplification DoS.
render_work: usize,
/// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget
/// of the normal drawing that follows.
shadow_work: usize,
}
impl Default for Renderer {
@@ -333,6 +340,9 @@ impl Default for Renderer {
view: Affine::IDENTITY,
group_fill: None,
morphs: morph_path::Cache::default(),
in_shadow_pass: false,
render_work: 0,
shadow_work: 0,
}
}
}
@@ -678,6 +688,9 @@ impl Renderer {
}
fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.in_shadow_pass = false;
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) {
@@ -690,6 +703,9 @@ impl Renderer {
}
fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.in_shadow_pass = false;
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) {
@@ -721,6 +737,9 @@ impl Renderer {
opts: &RenderOpts,
) -> Image {
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
self.render_work = 0;
self.shadow_work = 0;
self.in_shadow_pass = false;
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.
@@ -768,6 +787,9 @@ impl Renderer {
}
fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.in_shadow_pass = false;
let (w, h) = output_size(pres, opts);
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
@@ -896,6 +918,11 @@ impl Renderer {
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
return;
}
let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work };
*work = work.saturating_add(1);
if *work > 100_000 {
return;
}
// Footer placeholders on layouts/masters show only on slides that include them.
let st = f.opts.state.map(|g| g(s.id)).unwrap_or_default();
if !st.visible || st.opacity <= 0.001 {
@@ -936,7 +963,9 @@ impl Renderer {
// A group's outer shadow is cast by everything in it, as PowerPoint draws it: the
// children drawn once more under the shadow's offset, scale and skew, recoloured to
// the shadow colour and blurred, below the group.
if let Some(sh) = s.effects.as_ref().and_then(|e| e.outer_shadow.as_ref()) {
if !self.in_shadow_pass
&& let Some(sh) = s.effects.as_ref().and_then(|e| e.outer_shadow.as_ref())
{
let c = rctx.color(&sh.color, None);
let k = scale_of(m);
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
@@ -948,9 +977,11 @@ impl Renderer {
let shift = pre * m * own * m.inverse();
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
self.in_shadow_pass = true;
for ch in children {
self.shape(ctx, f, rctx, ch, shift * gm, depth + 1);
}
self.in_shadow_pass = false;
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
ctx.set_paint(color(c.with_alpha(255), 1.0));
@@ -1025,6 +1056,51 @@ impl Renderer {
}
}
/// Fill the filled sub-paths of `geo` (a `w` × `h` shape placed by `m`) with `fl`, faded per
/// sub-path by `fade`: `useBgFill` shows the slide background through the shape and `grpFill`
/// the group's fill.
#[allow(clippy::too_many_arguments)]
fn fill_geometry(
&self,
ctx: &mut RenderContext,
f: &Frame,
rctx: &Ctx,
fl: &Fill,
fill_ph: Option<Rgba>,
geo: &Geometry,
(w, h): (f64, f64),
m: Affine,
fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade,
) {
for (i, sp) in geo.paths.iter().enumerate() {
if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
continue;
}
ctx.set_transform(m);
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
match (fl, &self.bg) {
// `useBgFill`: the slide's background, laid out on the slide (not the
// shape), shows through the shape.
(Fill::Background, Some((bg, ph, view))) => {
let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
let path = (view.inverse() * m) * sp.path.clone();
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
}
// `grpFill`: the group's fill, laid out over the whole group.
(Fill::Group, _) => {
if let Some((gf, gm, gbox)) = &self.group_fill
&& gm.determinant().abs() > 1e-12
{
let path = (gm.inverse() * m) * sp.path.clone();
paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres);
}
}
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
}
ctx.set_fill_rule(peniko::Fill::NonZero);
}
}
#[allow(clippy::too_many_arguments)]
fn geometry_shape(
&mut self,
@@ -1121,6 +1197,13 @@ impl Renderer {
// Fill.
match &s.kind {
ShapeKind::Picture { fill: pf } => {
// The picture's own fill shows behind its transparent pixels, as in PowerPoint (an
// emphasis tint recolours it, as it does any shape's fill).
if s.fill.as_ref().is_some_and(|f| !f.is_none())
&& let Some(fl) = &fill
{
self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade);
}
ctx.set_transform(m);
let bounds = Rect::new(0.0, 0.0, w, h);
ctx.push_clip_layer(&outline);
@@ -1131,6 +1214,13 @@ impl Renderer {
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
// preview picture, stretched over the frame, as PowerPoint draws it.
ShapeKind::Opaque { preview: Some(media), .. } => {
// An embedded object's fallback picture can carry a fill (old equation objects
// drawn on coloured boxes): behind the preview, as for a picture.
if s.fill.as_ref().is_some_and(|f| !f.is_none())
&& let Some(fl) = &fill
{
self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade);
}
ctx.set_transform(m);
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None);
@@ -1155,33 +1245,7 @@ impl Renderer {
if let Some(fl) = &fill
&& !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none())
{
for (i, sp) in geo.paths.iter().enumerate() {
if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
continue;
}
ctx.set_transform(m);
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
match (fl, &self.bg) {
// `useBgFill`: the slide's background, laid out on the slide (not the
// shape), shows through the shape.
(Fill::Background, Some((bg, ph, view))) => {
let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
let path = (view.inverse() * m) * sp.path.clone();
paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
}
// `grpFill`: the group's fill, laid out over the whole group.
(Fill::Group, _) => {
if let Some((gf, gm, gbox)) = &self.group_fill
&& gm.determinant().abs() > 1e-12
{
let path = (gm.inverse() * m) * sp.path.clone();
paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres);
}
}
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
}
ctx.set_fill_rule(peniko::Fill::NonZero);
}
self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade);
}
}
}
@@ -1925,16 +1989,21 @@ fn trim_ends(path: &BezPath, start: f64, end: f64) -> BezPath {
&& start > 0.0
{
let mut left = start;
while let Some(seg) = segs.first().copied() {
let mut drop_count = 0;
for (i, seg) in segs.iter().enumerate() {
let l = seg.arclen(ACC);
if l > left {
if let Some(s0) = segs.first_mut() {
*s0 = seg.subsegment(seg.inv_arclen(left, ACC)..1.0);
}
let partial = seg.subsegment(seg.inv_arclen(left, ACC)..1.0);
segs.drain(..i);
segs[0] = partial;
drop_count = 0;
break;
}
left -= l;
segs.remove(0);
drop_count = i + 1;
}
if drop_count > 0 {
segs.drain(..drop_count);
}
}
if let Some((segs, false)) = subs.last_mut()
@@ -2243,6 +2312,20 @@ pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) {
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
if let Some(&(first, count)) = l.para_lines.get(para) {
if count == 0 {
return 0;
}
let end = (first + count).min(l.lines.len());
if first < end {
return l.lines[first..end]
.iter()
.enumerate()
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
.map(|(i, _)| first + i)
.unwrap_or(0);
}
}
l.lines
.iter()
.enumerate()
+27 -15
View File
@@ -138,8 +138,17 @@ fn border_look(ctx: &Ctx, b: &TableBorder) -> (Rgba, f64) {
}
}
pub fn resolve_style<'a>(ctx: &'a Ctx, t: &Table) -> Option<&'a TableStyleDef> {
ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style))
}
#[allow(dead_code)]
pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
if let Some(def) = ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) {
look_with_def(ctx, resolve_style(ctx, t), t, r, c)
}
pub fn look_with_def(ctx: &Ctx, def: Option<&TableStyleDef>, t: &Table, r: usize, c: usize) -> CellLook {
if let Some(def) = def {
return look_def(ctx, def, t, r, c);
}
let a = accent(ctx, &t.style);
@@ -242,7 +251,11 @@ fn edges(sizes: &[f64]) -> Vec<f64> {
/// A cell's text as drawn: its margins, anchor and direction, plus the table style's text colour and bold.
pub fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
let lk = look(rctx, t, r, c);
cell_body_with_def(rctx, resolve_style(rctx, t), t, r, c, cell)
}
pub fn cell_body_with_def(rctx: &Ctx, def: Option<&TableStyleDef>, t: &Table, r: usize, c: usize, cell: &Cell) -> TextBody {
let lk = look_with_def(rctx, def, 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);
@@ -272,6 +285,11 @@ pub fn cell_body(rctx: &Ctx, t: &Table, r: usize, c: usize, cell: &Cell) -> Text
/// 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> {
row_heights_with_def(rctx, resolve_style(rctx, t), t, fields)
}
/// [`row_heights`] with the table's style already resolved, for a caller that needs it too.
fn row_heights_with_def(rctx: &Ctx, style_def: Option<&TableStyleDef>, 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() {
@@ -282,7 +300,7 @@ pub fn row_heights(rctx: &Ctx, t: &Table, fields: &dyn Fields) -> Vec<f64> {
}
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 body = cell_body_with_def(rctx, style_def, t, ri, ci, cell);
let tmp = Shape::table_cell(body.clone());
let need = deckcraft_text::fit_height(rctx, &tmp, &body, Rect::new(*x0, 0.0, *x1, h.max(1.0)), fields);
if need.is_finite() {
@@ -347,16 +365,10 @@ pub fn draw(
editing: bool,
) -> Option<kurbo::Rect> {
let _ = pres;
let rects = cell_rects(t, &row_heights(rctx, t, fields));
let style_def = resolve_style(rctx, t);
let rects = cell_rects(t, &row_heights_with_def(rctx, style_def, t, fields));
let covered = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b));
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
let style_bg = rctx
.pres
.table_styles
.iter()
.find(|d| d.id.eq_ignore_ascii_case(&t.style))
.and_then(|d| d.background.as_ref())
.and_then(|b| style_fill(rctx, b));
let style_bg = style_def.and_then(|d| d.background.as_ref()).and_then(|b| style_fill(rctx, b));
let own_bg = t.background.clone().map(|f| (f, None));
if let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) {
for (bg, ph) in style_bg.into_iter().chain(own_bg) {
@@ -366,7 +378,7 @@ pub fn draw(
}
for (r, c, rect) in &rects {
let Some(cell) = t.cell(*r, *c) else { continue };
let lk = look(rctx, t, *r, *c);
let lk = look_with_def(rctx, style_def, t, *r, *c);
ctx.set_transform(m);
match &cell.fill {
Some(f) => {
@@ -399,7 +411,7 @@ pub fn draw(
// Borders: explicit cell borders, else the style's grid.
for (r, c, rect) in &rects {
let Some(cell) = t.cell(*r, *c) else { continue };
let lk = look(rctx, t, *r, *c);
let lk = look_with_def(rctx, style_def, t, *r, *c);
let edges = [
(rect.x0, rect.y0, rect.x0, rect.y1),
(rect.x1, rect.y0, rect.x1, rect.y1),
@@ -439,7 +451,7 @@ pub fn draw(
if (cell.text.is_empty() && !editing) || text_off_target(cell, *rect, m, (ctx.width(), ctx.height())) {
continue;
}
let body = cell_body(rctx, t, *r, *c, cell);
let body = cell_body_with_def(rctx, style_def, t, *r, *c, cell);
let tmp = Shape::table_cell(body.clone());
#[cfg(test)]
TEXT_CELLS_DRAWN.with(|n| n.set(n.get() + 1));
+41 -1
View File
@@ -1,4 +1,8 @@
use super::*;
use super::{
Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph,
crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide,
render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
};
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
use deckcraft_model::style::ColorRef;
use deckcraft_model::text::TextBody;
@@ -2009,6 +2013,42 @@ fn group_child_scale_does_not_scale_line_widths() {
}
}
#[test]
fn a_picture_and_an_embedded_objects_preview_draw_their_fill_behind_transparent_pixels() {
// PowerPoint draws a picture's own fill behind its transparent pixels (and an old equation
// object's fallback picture on its coloured box). Here the left half of the image is clear.
let png = {
let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([20, 20, 160, 255]) });
let mut out = std::io::Cursor::new(vec![]);
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
};
let orange = Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 153, 0)));
for (object, fill) in [(false, None), (false, Some(orange.clone())), (true, Some(orange.clone()))] {
let mut p = Presentation::default();
let media = p.add_media("half.png", "image/png", png.clone()).expect("media");
let kind = if object {
ShapeKind::Opaque { xml: String::new(), preview: Some(media), label: "Equation.3".into(), parts: vec![] }
} else {
ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }
};
let sh = Shape { id: ShapeId(630), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind, fill: fill.clone(), ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let (clear, opaque) = (img.pixel(200, 200), img.pixel(400, 200));
assert!(opaque[2] > 140 && opaque[0] < 40, "the picture's own pixels ({object}): {opaque:?}");
if fill.is_some() {
assert_eq!(clear[..3], [255, 153, 0], "the fill behind the clear half ({object})");
// A fill-colour emphasis recolours that fill, as it does any shape's.
let tint = |_: ShapeId| ShapeState { tint: Some(deckcraft_color::Rgba::rgb(0, 160, 60)), ..Default::default() };
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, state: Some(&tint), ..Default::default() });
assert_eq!(img.pixel(200, 200)[..3], [0, 160, 60], "the tinted fill behind the clear half ({object})");
} else {
assert!(clear[..3].iter().all(|v| *v > 240), "no fill: the slide shows through: {clear:?}");
}
}
}
#[test]
fn a_picture_casts_its_shadow_from_its_pixels() {
// PowerPoint shades only where a picture paints: a logo on a transparent background casts the
+14 -1
View File
@@ -1,10 +1,14 @@
//! Render tests for shape, line, fill and effect cases checked against PowerPoint's own exports
//! of synthetic "wild" slides (observed black-box; see `plan/powerpoint/tools/gen_wild.py`).
use super::*;
use super::{
BezPath, Fill, Image, Line, MAX_DASHES, PathEl, Point, Rect, RenderOpts, ShapeKind, dash_with_caps, head_size, images, limit_dash_count, paint,
render_slide, trim_ends,
};
use deckcraft_color::Rgba;
use deckcraft_model::style::ColorRef;
use deckcraft_model::{CustomPath, Geom, Presentation, Shape, ShapeId, Xfrm};
use kurbo::Shape as _;
fn deck_with(shapes: Vec<Shape>) -> Presentation {
let mut p = Presentation::default();
@@ -160,6 +164,15 @@ fn trimming_a_path_leaves_its_ends_off_by_arc_length() {
assert!(trim_ends(&p, 60.0, 50.0).elements().is_empty());
let r = Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.1);
assert_eq!(trim_ends(&r, 3.0, 3.0).elements().len(), r.elements().len());
// Zero-length leading segments trimmed correctly without quadratic copy.
let mut p0 = BezPath::new();
p0.move_to((0.0, 0.0));
p0.line_to((0.0, 0.0));
p0.line_to((0.0, 0.0));
p0.line_to((50.0, 0.0));
let t0 = trim_ends(&p0, 10.0, 0.0);
assert_eq!(t0.elements().first(), Some(&PathEl::MoveTo(Point::new(10.0, 0.0))));
}
fn radial(focus: [f64; 4]) -> Shape {
+17
View File
@@ -16,6 +16,23 @@ const MAX_POINTS: usize = 40_000;
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to.
fn line_of(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
if let Some(&(first, count)) = l.para_lines.get(para) {
if count == 0 {
return None;
}
let end = (first + count).min(l.lines.len());
if first < end {
let mut best: Option<(usize, f64)> = None;
for (rel_i, li) in l.lines[first..end].iter().enumerate() {
let inside = y >= li.top - 0.01 && y <= li.bottom + 0.01;
let d = if inside { 0.0 } else { (li.baseline - y).abs() };
if best.is_none_or(|(_, bd)| d < bd) {
best = Some((first + rel_i, d));
}
}
return best.map(|(i, _)| i);
}
}
let mut best: Option<(usize, f64)> = None;
for (i, li) in l.lines.iter().enumerate().filter(|(_, li)| li.para == para) {
let inside = y >= li.top - 0.01 && y <= li.bottom + 0.01;
+6 -1
View File
@@ -1,4 +1,9 @@
use super::*;
use super::{
Affinity, Align, Anchor, Arc, AutoFit, BodyProps, Bullet, Caps, Cell, Ctx, Dash, Deco, DecoLine, FontDb, FontFace, GlyphRun, LINE_PITCH,
MoveMode, NoFields, Opts, ParaProps, Point, Pos, Rect, Rgba, SUB_SUPER, Shape, Spacing, Strike, TextBody, TextLayout, autonum,
break_opportunities, caps_char, chinese_num, covers_symbol, is_symbol_font, is_upright, layout, layout_scaled, map_symbol_bullet, measure_str,
resolve, shaping_features, symbol_char, symbol_font_char, symbol_font_draw, symbol_unicode, symbol_unicode_alts,
};
use deckcraft_model::Fill;
use deckcraft_model::defaults;
use deckcraft_model::text::{Paragraph, Run};
+3
View File
@@ -24,6 +24,9 @@ serde_json = { workspace = true }
log = { workspace = true }
web-time = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
[dev-dependencies]
skrifa = { workspace = true }
# kittest's own dev-dependencies do not propagate; tests need both font features explicitly.
+1 -1
View File
@@ -12,7 +12,7 @@ use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use super::*;
use crate::backstage::{Backstage, NAV_W, Nav, Page, PrintScope, entries, format_bytes, print_page_size, print_params, print_slides, split_path};
use crate::{Services, SlideApp};
fn harness(app: SlideApp) -> Harness<'static, SlideApp> {
+30 -22
View File
@@ -363,7 +363,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
draw_guides(app, &painter, xf, slide_rect, &t);
}
if app.ui.ruler {
ruler(&painter, avail, slide_rect, scale, &t);
ruler(&painter, avail, slide_rect, scale, app.session.prefs.units, &t);
ruler_markers(app, ui, &painter, avail, xf, &t);
}
pointer(app, ui, &resp, xf);
@@ -579,44 +579,52 @@ fn master_tex(
Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region))
}
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
/// The ruler's numbered step in points and the ticks per step: quarter inches, or half
/// centimetres. Any other unit (only a hand-edited `prefs.json` gives one) keeps inches, since a
/// tick per point or pixel would be a solid bar.
pub(crate) fn ruler_steps(unit: deckcraft_geom::Unit) -> (f64, u32) {
match unit {
deckcraft_geom::Unit::Centimeters => (unit.points_per_unit(), 2),
_ => (72.0, 4),
}
}
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, unit: deckcraft_geom::Unit, t: &Tokens) {
let top = Rect::from_min_max(avail.min, pos2(avail.max.x, avail.min.y + 16.0));
let left = Rect::from_min_max(avail.min, pos2(avail.min.x + 16.0, avail.max.y));
p.rect_filled(top, CornerRadius::ZERO, t.card);
p.rect_filled(left, CornerRadius::ZERO, t.card);
let inch = 72.0 * scale;
let mut k = 0;
loop {
let x = slide.min.x + k as f32 * inch / 4.0;
if x > slide.max.x {
break;
}
let h = if k % 4 == 0 {
let (step, sub) = ruler_steps(unit);
let tick = step as f32 * scale / sub as f32;
let len = |k: u32| {
if k.is_multiple_of(sub) {
8.0
} else if k % 2 == 0 {
} else if k.is_multiple_of(2) {
5.0
} else {
3.0
};
}
};
let mut k = 0;
loop {
let x = slide.min.x + k as f32 * tick;
if x > slide.max.x {
break;
}
let h = len(k);
p.line_segment([pos2(x, top.max.y - h), pos2(x, top.max.y)], Stroke::new(1.0, t.text_faint));
if k % 4 == 0 {
p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / 4), theme::font(8.0), t.text_dim);
if k.is_multiple_of(sub) {
p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / sub), theme::font(8.0), t.text_dim);
}
k += 1;
}
let mut k = 0;
loop {
let y = slide.min.y + k as f32 * inch / 4.0;
let y = slide.min.y + k as f32 * tick;
if y > slide.max.y {
break;
}
let w = if k % 4 == 0 {
8.0
} else if k % 2 == 0 {
5.0
} else {
3.0
};
let w = len(k);
p.line_segment([pos2(left.max.x - w, y), pos2(left.max.x, y)], Stroke::new(1.0, t.text_faint));
k += 1;
}
+74 -12
View File
@@ -214,6 +214,34 @@ fn check(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: bool) ->
v
}
/// A Slide Size field set to `pt`: two decimals of the unit shown, the exact points kept beside.
fn put_slide_dim(d: &mut Dialog, key: &str, pt: f64, ppu: f64) {
d.fields.insert(key.into(), format!("{:.2}", pt / ppu));
d.fields.insert(format!("{key}Pt"), pt.to_string());
}
/// A Slide Size field in points: the exact value while the text is still its rendering, else
/// the number typed (`default` when it is not one).
fn slide_dim_points(d: &Dialog, key: &str, ppu: f64, default: f64) -> f64 {
let text = field_of(d, key);
if let Some(pt) = exact_of(d, key)
&& text == format!("{:.2}", pt / ppu)
{
return pt;
}
text.trim().trim_end_matches(['"', '%', ' ']).parse::<f64>().ok().filter(|v| v.is_finite()).map_or(default, |v| v * ppu)
}
/// The unit's abbreviation in a field label, "Width (cm):".
fn unit_abbr(unit: deckcraft_geom::Unit) -> &'static str {
match unit {
deckcraft_geom::Unit::Inches => "in",
deckcraft_geom::Unit::Centimeters => "cm",
deckcraft_geom::Unit::Points => "pt",
deckcraft_geom::Unit::Pixels => "px",
}
}
fn num(d: &mut Dialog, key: &str, default: f64) -> f64 {
d.get(key, "").trim().trim_end_matches(['"', '%', ' ']).parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(default)
}
@@ -1073,35 +1101,46 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
"slideSize" => {
let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE);
let len = app.session.prefs.units;
let ppu = len.points_per_unit();
// Each field keeps its exact size in points (`wPt`) beside the two decimals shown, so
// a preset, Portrait or an untouched field is not nudged off by the rounding (13.33",
// 33.87 cm read back would make the slide 959.76 or 960.09 pt wide).
if !d.fields.contains_key("wPt") {
put_slide_dim(d, "w", cur.width, ppu);
put_slide_dim(d, "h", cur.height, ppu);
}
let label = ui.label("Slides sized for:");
let preset = d.get("preset", "");
let combo = egui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| {
for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES {
if ui.selectable_label(preset == *label, *label).clicked() {
d.fields.insert("preset".into(), label.to_string());
d.fields.insert("w".into(), format!("{:.2}", w / 72.0));
d.fields.insert("h".into(), format!("{:.2}", h / 72.0));
put_slide_dim(d, "w", *w, ppu);
put_slide_dim(d, "h", *h, ppu);
}
}
});
let _ = combo.response.labelled_by(label.id);
field(ui, d, "Width (in):", "w", &format!("{:.2}", cur.width / 72.0));
field(ui, d, "Height (in):", "h", &format!("{:.2}", cur.height / 72.0));
let (w, h) = (num(d, "w", cur.width / 72.0), num(d, "h", cur.height / 72.0));
let abbr = unit_abbr(len);
let (w_seed, h_seed) = (field_of(d, "w"), field_of(d, "h"));
field(ui, d, &format!("Width ({abbr}):"), "w", &w_seed);
field(ui, d, &format!("Height ({abbr}):"), "h", &h_seed);
let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height));
ui.horizontal(|ui| {
ui.label("Orientation:");
let portrait = ui.radio(h > w, "Portrait").clicked();
let landscape = ui.radio(w >= h, "Landscape").clicked();
// Picking the other orientation swaps the width and height fields.
if portrait && w > h || landscape && h > w {
d.fields.insert("w".into(), format!("{h:.2}"));
d.fields.insert("h".into(), format!("{w:.2}"));
put_slide_dim(d, "w", h, ppu);
put_slide_dim(d, "h", w, ppu);
}
});
let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false);
let (ok, cancel) = buttons(ui, "OK");
if ok {
let (w, h) = (num(d, "w", cur.width / 72.0) * 72.0, num(d, "h", cur.height / 72.0) * 72.0);
let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height));
run(app, "design.slideSize", json!({"w": w, "h": h, "scale": if maximize { "maximize" } else { "ensureFit" }}));
}
ok || cancel
@@ -1645,9 +1684,11 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
app.set_status("Tick at least one slide to insert".to_string());
return false;
}
app.reuse_slides(&path, keep, &picked);
// A failed insert keeps the dialog (and the ticked slides) open; the error is in the status bar.
let source = d.params.get("source").cloned().unwrap_or_default();
return app.reuse_slides(&source, keep, &picked);
}
ok || cancel
cancel
}
"renameSection" | "renameShape" | "renameLayout" => {
let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string();
@@ -2056,7 +2097,21 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let (cur, np) = selected_language(app);
let lang = d.get("lang", &cur);
let mut no_proof = d.get("noProof", if np { "1" } else { "" }) == "1";
ui.label("Mark selected text as:");
// The selected text, or every text in the presentation (all there is with nothing selected).
let has_sel = deckcraft_engine::cmd::has_text_or_shapes(&app.session).is_ok();
let mut all = !has_sel || d.get("scope", "") == "all";
ui.horizontal(|ui| {
ui.label("Apply to:");
if ui.add_enabled(has_sel, egui::RadioButton::new(!all, "Selected text")).clicked() {
d.fields.insert("scope".into(), "sel".into());
all = false;
}
if ui.radio(all, "Whole presentation").clicked() {
d.fields.insert("scope".into(), "all".into());
all = true;
}
});
ui.label(if all { "Mark all text in the presentation as:" } else { "Mark selected text as:" });
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
// The selection's own language first when the list doesn't have it.
let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone()));
@@ -2080,7 +2135,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let (ok, cancel) = buttons(ui, "OK");
if ok {
let lang = d.get("lang", &cur);
run(app, "format.language", json!({"lang": lang, "noProof": no_proof}));
run(app, if all { "format.languageAll" } else { "format.language" }, json!({"lang": lang, "noProof": no_proof}));
}
ok || cancel
}
@@ -2307,6 +2362,13 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
d.fields.remove("uiScale");
}
// PowerPoint takes the unit from the system's measurement setting; DeckCraft asks here.
ui.horizontal(|ui| {
ui.label("Measurement units:");
let units = &mut app.session.prefs.units;
ui.radio_value(units, deckcraft_geom::Unit::Inches, "Inches");
ui.radio_value(units, deckcraft_geom::Unit::Centimeters, "Centimeters");
});
ui.label(egui::RichText::new("Editing").font(theme::bold(13.0)));
ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides");
ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid");
+141 -7
View File
@@ -36,6 +36,8 @@ pub mod theme;
pub mod thumbs;
pub mod toast;
pub mod uifonts;
#[cfg(not(target_arch = "wasm32"))]
mod updates_ui;
pub mod widgets;
use std::collections::HashMap;
@@ -439,6 +441,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
"{layout?, perPage?, slides?, includeHidden?, frame?, paper?: letter|a4|legal|a3, orientation?: portrait|landscape}",
),
("app.about", "About DeckCraft", None, "{}"),
("app.checkUpdates", "Check for Updates…", None, "{}"),
("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"),
("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),
("app.openDialog", "Open…", None, "{purpose?}"),
@@ -534,6 +537,8 @@ pub struct SlideApp {
pub show: Option<show::Show>,
/// Current slide's animations playing on the editor canvas (not the full-screen show).
anim_preview: Option<AnimPreview>,
/// Where the Reuse Slides picker inserts: the presentation and slide current when it opened.
reuse_target: Option<FileTarget>,
/// Audio/video playback (editor control bar, slide show, `media.*` commands).
pub media: media::MediaHost,
pub status: Option<(String, f64)>,
@@ -592,6 +597,8 @@ pub struct SlideApp {
/// creating or closing a presentation leaves it.
pub backstage: Option<backstage::Backstage>,
pending_urls: Vec<String>,
#[cfg(not(target_arch = "wasm32"))]
updates_rt: updates_ui::Runtime,
/// Text for the system clipboard from a Copy or Cut command, handed over on the next frame.
pending_copy: Option<String>,
/// Bounded screenshot completion slots for the control channel (egui 0.37 callbacks).
@@ -806,6 +813,7 @@ impl SlideApp {
thesaurus_query: String::new(),
show: None,
anim_preview: None,
reuse_target: None,
media: media::MediaHost::new(audio_out),
status: None,
opening: None,
@@ -834,6 +842,8 @@ impl SlideApp {
integrated_titlebar: false,
backstage: None,
pending_urls: vec![],
#[cfg(not(target_arch = "wasm32"))]
updates_rt: updates_ui::Runtime::default(),
pending_copy: None,
capture: capture::Capture::default(),
styled: false,
@@ -878,6 +888,13 @@ impl SlideApp {
self.status = Some((msg.into(), now_ms()));
}
/// Turn on the automatic check for a newer release at start-up (desktop shell only; tests and
/// the web build never touch the network on their own).
#[cfg(not(target_arch = "wasm32"))]
pub fn enable_update_checks(&mut self) {
updates_ui::enable(self);
}
/// A message that must be seen wherever the user is: a toast, which also shows over the start
/// screen and the File tab, where there is no status bar.
pub fn notify(&mut self, msg: impl Into<String>) {
@@ -1352,6 +1369,12 @@ impl SlideApp {
return Ok(json!({"printed": self.print_pdf(p.clone())}));
}
"app.about" => self.dialog = Some(dialogs::Dialog::new("about")),
"app.checkUpdates" => {
#[cfg(not(target_arch = "wasm32"))]
updates_ui::request_check(self);
#[cfg(target_arch = "wasm32")]
return Err("updates are handled by the browser: reload the page".into());
}
"app.palette" => self.palette = Some((String::new(), 0)),
"app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")),
"app.openDialog" => self.pick_and_open(),
@@ -1609,22 +1632,55 @@ impl SlideApp {
return;
};
let Some(path) = pick("open") else { return };
// Read the file once: the list shown and the slides inserted come from the same bytes, even
// if the file changes while the dialog is open. Without a reader the path is used as is.
let mut source = json!({});
match self.services.read.as_ref().map(|r| r(&path)) {
Some(Ok(bytes)) => {
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
source["name"] = json!(name);
source["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes));
}
Some(Err(e)) => {
self.set_status(e.to_string());
return;
}
None => source["path"] = json!(path),
}
// Offer the file's slides to choose from; all of them are ticked to begin with.
match self.session.execute("slide.reuseList", &json!({"path": path})) {
match self.session.execute("slide.reuseList", &source) {
Ok(list) => {
let mut d = dialogs::Dialog::new("reuseSlides");
d.params = json!({"path": path, "keep": keep, "slides": list["slides"]});
d.params = json!({"path": path, "source": source, "keep": keep, "slides": list["slides"]});
self.reuse_target = Some(self.capture_file_target());
self.dialog = Some(d);
}
Err(e) => self.set_status(e.to_string()),
}
}
/// Insert the slides `picked` (indices) of the presentation at `path` after the current slide.
pub fn reuse_slides(&mut self, path: &str, keep: bool, picked: &[usize]) {
match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep, "slides": picked})) {
Ok(r) => self.set_status(format!("Inserted {} slides from {path}", r["slides"])),
Err(e) => self.set_status(e.to_string()),
/// Insert the slides `picked` (indices) of `source` (the `name` + `data`, or `path`, that
/// `slide.reuseList` listed) after the slide that was current when the picker opened, in that
/// presentation, even if another one is active now; if it is closed or the slide is gone,
/// nothing is inserted. Returns whether they were inserted.
pub fn reuse_slides(&mut self, source: &Value, keep: bool, picked: &[usize]) -> bool {
let mut args = if source.is_object() { source.clone() } else { json!({}) };
args["keepFormatting"] = json!(keep);
args["slides"] = json!(picked);
let result = match self.reuse_target {
Some(target) if !self.viewing_target(&target) => self.execute_on_target(&target, "Reuse Slides", "slide.reuse", &args),
_ => self.session.execute("slide.reuse", &args).map(|_| ()).map_err(|e| e.to_string()),
};
match result {
Ok(()) => {
self.reuse_target = None;
self.set_status(format!("Inserted {} slides", picked.len()));
true
}
Err(e) => {
self.set_status(e);
false
}
}
}
@@ -1982,6 +2038,8 @@ impl SlideApp {
if !self.synthetic_batch_is_live(ctx) {
self.drain_control(ctx);
self.poll_opening();
#[cfg(not(target_arch = "wasm32"))]
updates_ui::tick(self, ctx);
// Inbox results are host file bytes and do not need fonts or a painted frame. A
// window that starts hidden must still apply a file picked before its first UI pass.
self.drain_inbox();
@@ -3312,6 +3370,8 @@ impl SlideApp {
toast::note_missing_fonts(self);
toast::show(self, &ctx);
dialogs::show(self, &ctx);
#[cfg(not(target_arch = "wasm32"))]
updates_ui::show(self, &ctx);
menus::palette(self, &ctx);
for url in std::mem::take(&mut self.pending_urls) {
ctx.open_url(egui::OpenUrl::new_tab(url));
@@ -3513,6 +3573,8 @@ mod last_color_tests;
#[cfg(test)]
mod replacefonts_tests;
#[cfg(test)]
mod reuse_picker_tests;
#[cfg(test)]
mod slide_size_tests;
#[cfg(test)]
mod split_button_tests;
@@ -3520,6 +3582,8 @@ mod split_button_tests;
mod texture_limit_tests;
#[cfg(test)]
mod ui_soak_tests;
#[cfg(test)]
mod units_tests;
#[cfg(test)]
#[path = "datetime_tests.rs"]
@@ -4532,6 +4596,76 @@ mod tests {
assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR"));
}
/// Review > Language > Whole presentation marks every text box, not only the selected one.
#[test]
fn the_language_dialog_marks_the_whole_presentation() {
use egui_kittest::kittest::Queryable;
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(Session::with_new(), Services::default()),
);
harness.run_steps(2);
let s = &mut harness.state_mut().session;
let title = s.active().unwrap().doc.slides[0].shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "Hola"})).unwrap();
s.execute("slide.new", &json!({"title": "Adiós"})).unwrap();
s.execute("text.edit", &json!({"id": s.active().unwrap().doc.slides[1].shapes[0].id.0})).unwrap();
s.execute("text.selectAll", &json!({})).unwrap();
harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap();
harness.run_steps(4);
harness.get_by_label("Whole presentation").click();
harness.run_steps(2);
harness.get_by_label("Español (España)").click();
harness.run_steps(2);
harness.get_by_label("OK").click();
harness.run_steps(2);
assert!(harness.state().dialog.is_none());
let d = harness.state().session.active().unwrap();
for sl in &d.doc.slides {
let body = sl.shapes[0].text.as_ref().unwrap();
assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES"));
}
}
/// With nothing selected, Review > Language applies to the whole presentation without
/// choosing it, and Selected text can't be picked.
#[test]
fn the_language_dialog_marks_the_whole_presentation_with_nothing_selected() {
use egui_kittest::kittest::{NodeT, Queryable};
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(Session::with_new(), Services::default()),
);
harness.run_steps(2);
let s = &mut harness.state_mut().session;
let title = s.active().unwrap().doc.slides[0].shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "Hola"})).unwrap();
s.execute("slide.new", &json!({"title": "Adiós"})).unwrap();
s.execute("edit.select", &json!({"ids": []})).unwrap();
assert!(s.active().unwrap().selection.text.is_none() && s.active().unwrap().selection.shapes.is_empty());
harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap();
harness.run_steps(4);
assert!(harness.get_by_label("Selected text").accesskit_node().is_disabled());
harness.get_by_label("Español (España)").click();
harness.run_steps(2);
harness.get_by_label("OK").click();
harness.run_steps(2);
assert!(harness.state().dialog.is_none());
let d = harness.state().session.active().unwrap();
for sl in &d.doc.slides {
let body = sl.shapes[0].text.as_ref().unwrap();
assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("es-ES"));
}
}
/// Speaker notes keep their formatting when edited in the notes pane: typing into a bold
/// heading, Enter in a bulleted point, then saving as .pptx and reopening.
#[test]
+32
View File
@@ -736,6 +736,38 @@ mod tests {
b
}
/// A video whose codec has no decoder (a VP8 WebM, #1237) still plays through the real
/// insert + play path: its audio track sounds, and the insert said the video won't show.
#[test]
fn a_webm_with_an_undecodable_video_codec_still_plays_its_audio() {
use deckcraft_engine::Session;
let bytes = include_bytes!("../../media/tests/fixtures/clip-vp8.webm");
let mut s = Session::with_new();
let _ = s.execute("slide.new", &serde_json::json!({"layout": "blank"}));
let r = s
.execute("insert.video", &serde_json::json!({"name": "clip.webm", "data": deckcraft_engine::cmd::base64_encode(bytes)}))
.expect("insert");
assert_eq!(r["playable"], true, "the Vorbis track plays: {r}");
let w = r["warning"].as_str().expect("the insert says the video won't show");
assert!(w.contains("VP8") && w.contains("only the audio plays"), "{w}");
let (uid, doc) = s.active().map(|d| (d.uid, d.doc.clone())).expect("document");
let id = ShapeId(r["id"].as_u64().expect("id") as u32);
let clip = find_clip(&doc, id, Some(0)).expect("clip");
let mut host = MediaHost::new(None);
host.play(uid, &doc, id, &clip, None, Owner::Show);
assert!(host.player.wait_loaded(key(id), std::time::Duration::from_secs(5)), "decode");
assert!(host.status(id).is_some_and(|s| s.error.is_none()), "Vorbis decodes");
let mut buf = vec![0.0f32; 22_050];
host.player.render(&mut buf, 1, 44_100);
// The fixture's sine is −21 dB, so a peak near 0.09 means it is sounding.
let peak = buf.iter().fold(0.0f32, |m, s| m.max(s.abs()));
assert!(peak > 0.04, "the audio track sounds: peak {peak}");
assert!(host.videos.get(&id).is_some_and(|v| v.feed.is_none()), "VP8 opens no video feed");
host.tick_clock(0.1, &mut s);
host.sync_video_textures(&egui::Context::default());
assert!(host.texture(id).is_none(), "no VP8 frame exists to show");
}
#[test]
fn grouped_gifs_play_in_slide_space() {
let mut doc = Presentation::default();
+1 -1
View File
@@ -289,7 +289,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
("Set Up Show…", "show.setup"),
],
),
("Help", vec![("About DeckCraft", "app.about"), ("Command Palette", "app.palette")]),
("Help", vec![("About DeckCraft", "app.about"), ("Check for Updates…", "app.checkUpdates"), ("Command Palette", "app.palette")]),
]
}
+9 -7
View File
@@ -331,10 +331,12 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
let lock_id = egui::Id::unique("fmt-lock-aspect");
let mut lock: bool = ui.data_mut(|d| d.get_temp(lock_id).unwrap_or(false));
let locked = lock;
let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, unit: f64| {
let len = app.session.prefs.units;
let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, angle: bool| {
let (unit, suffix) = if angle { (1.0, "°") } else { (len.points_per_unit(), len.suffix()) };
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
if let Some(nv) = widgets::spinner(ui, id, label, v / unit, 0.1, if unit == 72.0 { "\"" } else { "°" }, 70.0) {
if let Some(nv) = widgets::spinner(ui, id, label, v / unit, 0.1, suffix, 70.0) {
let mut p = json!({key: nv * unit});
if cmd == "shape.resize" && locked {
p["lockAspect"] = json!(true);
@@ -344,16 +346,16 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
});
};
ui.label(egui::RichText::new("Size").font(theme::bold(13.0)));
fields(ui, "Height", "fh", x.h, "h", "shape.resize", 72.0);
fields(ui, "Width", "fw", x.w, "w", "shape.resize", 72.0);
fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", 1.0);
fields(ui, "Height", "fh", x.h, "h", "shape.resize", false);
fields(ui, "Width", "fw", x.w, "w", "shape.resize", false);
fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", true);
if ui.checkbox(&mut lock, "Lock aspect ratio").changed() {
ui.data_mut(|d| d.insert_temp(lock_id, lock));
}
ui.add_space(6.0);
ui.label(egui::RichText::new("Position").font(theme::bold(13.0)));
fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", 72.0);
fields(ui, "Vertical", "fy", x.y, "y", "shape.move", 72.0);
fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", false);
fields(ui, "Vertical", "fy", x.y, "y", "shape.move", false);
ui.add_space(6.0);
ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0)));
let id = ui.scope_id().with(("alt", uid, sh.id.0));

Some files were not shown because too many files have changed in this diff Show More