Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 6888c3800a ci: verify the actions lockfile in CI
Fails the build when .github/workflows/actions.lock stops matching the
workflows, or when a locked pin no longer verifies upstream, so a stale or
tampered lockfile cannot go unnoticed between dependency updates.
2026-11-12 07:22:59 -05:00
Dylan M. Taylor 26d0ecba74 ci: lock workflow actions through an actions.lock lockfile
Mutable action tags are a supply-chain risk: a moved tag runs different code
without a diff. Every workflow action is now pinned through
.github/workflows/actions.lock, and CI verifies the lockfile so an out-of-sync
or tampered one fails instead of running.
2026-11-12 07:16:24 -05:00
Dylan M. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> 9b1fe3ff23 PPTX: evaluate a custom geometry's guide lists once per coordinate space
* PPTX: evaluate a custom geometry's guide lists once per coordinate space

* address review: exempt guide-free geometries from the space cap

* address review: budget guide-name bytes cached across coordinate spaces

* Apply review fixes

- crates/pptx/src/custgeom.rs

* fix(pptx): warn when custom geometry paths are left out for the guide-cache limits

---------

Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-12 06:28:38 -05:00
Dylan M. Taylor 653f596958 Chart render: budget category-by-series cells
* Chart render: budget category-by-series cells before drawing

* fix(render): chart cell budget is Excel's 255 series x 32,000 points
2026-11-12 06:28:30 -05:00
Dylan M. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> 129efb16bd fix(pptx): bound the chartEx writer's caches and series
* fix(pptx): bound the chartEx writer's caches and series

* Apply review fixes

- crates/pptx/src/write/chart.rs

---------

Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-12 06:28:24 -05:00
autocraft-bot[bot] b8983ae65c Merge pull request #2075 from Hamza0101/fix/legend-entry-text
Charts: legend entries keep their own text size, weight and colour (#2067)
2026-11-12 10:04:49 +00:00
Hamza 8f93cd9099 Charts: the legend entry test checks the size as written, not only as read back 2026-11-12 14:49:36 +05:00
Hamza 0e0d7f8cb2 Charts: a legend entry's paragraph text wins over its list style
Review fixes: an entry's size, weight and colour are read from its
paragraph (a:p) first and from a:lstStyle only where the paragraph
sets none, as DrawingML inherits them; the writer keeps sizes to the
reader's 1-400 pt, so a size reads back as written; and the legend
draws an entry at its own size up to 400 pt rather than up to 8 times
the legend's.
2026-11-12 14:48:11 +05:00
autocraft-bot[bot] b5b77eb27e Apply review fixes
- crates/engine/src/cmd/insert/chart.rs
2026-11-12 08:01:45 +00:00
autocraft-bot[bot] b73a53df3d Apply review fixes
- crates/engine/src/cmd/format/font.rs
2026-11-12 07:46:39 +00:00
Hamza 1d608bb464 Charts: legend entries keep their own text size, weight and colour
A c:legendEntry can give one legend entry its own text (c:txPr), as
decks do to make legend text white on a dark chart. It was never read,
so every entry drew in the legend's text. The chart model keeps each
entry's size, bold and colour (Chart::legend_entries); the reader
takes them from the entry's c:txPr, the legend draws each name, key
and row at its entry's size and in its weight and colour, and a chart
rebuilt after an edit writes them back before c:overlay, in schema
order. Entries that set none of the three are not kept.

Fixes #2067
2026-11-12 12:20:32 +05:00
Hamza Naveed 6849fa0149 Pictures in a placeholder show the layout placeholder's fill (#2072)
A slide picture with an empty p:spPr in a layout picture placeholder
that sets a fill drew only its pixels: the picture path drew a fill
only when the picture set its own, so the beige frame the layout gives
the placeholder never showed behind a transparent image. A picture
that sets neither a fill nor a style now draws the explicit fill its
layout or master placeholder sets, in the placeholder's geometry. Its
own fill (a:noFill included) and its own style decide as before, and a
style fill alone, inherited or not, stays undrawn.

Fixes #2068
2026-11-12 07:00:59 +00:00
Hamza Naveed 6eebec7df6 Decrease Font Size steps down one size at a time from any size (#2070)
Ctrl+[ (and Cmd+Shift+,) shrank any size above 66 straight to 66: the
step list stopped at 66, so the largest list size below 120 was 66.
The list now holds PowerPoint's sizes up to 96, as the Font Size box
already listed them, and above 96 shrink steps back 8 points at a
time, as grow steps up: 120 -> 112 -> 104 -> 96 -> 88 -> 80 -> 72 ->
66. The Font Size box lists the same SIZES instead of adding its own
tail.

Fixes #2059
2026-11-12 06:16:48 +00:00
Dylan M. Taylor a82e37dee2 Video: decode H.264, HEVC, VP9 and AV1 with craft-video (#2065)
* Video: decode H.264, HEVC, VP9 and AV1 with craft-video; delete crates/h264, hevc, vp9, av1 and bitstream

* Video: pin craft-libs main with the DeckCraft decoder fixes; architecture notes

* Video: pin shared decoders to current craft-libs main
2026-11-12 00:02:30 -05:00
FredApps 2127bd1bd8 CI: nightly release dry run (#2052)
release.yml also runs nightly on main with craft-common-actions' dry-run: every release build
except macOS, with nothing signed, pushed or drafted. Breakage that only the release build hits
(riscv64, Flatpak, FreeBSD, packaging) then shows up the next morning instead of on release
day, as it did on 11 Nov 2026.
2026-11-11 23:03:54 +01:00
Dylan M. Taylor eec4f59535 AV1: decode tiles serially when parallel tile metadata copies would be huge (#1841) 2026-11-11 17:00:12 -05:00
Dylan M. Taylorandaiongg 988a492097 PPTX: copy only used, bounded namespace declarations onto imported fragments (#1740)
* pptx: copy only used, bounded namespace declarations onto imported fragments

* address review: index a branch's namespace declarations once, look up each child's prefixes

* address review: oversized declarations keep shadowing; keep MustUnderstand prefixes

* address review: keep declarations for prefixes used in QName attribute values

* Apply review fixes

- crates/pptx/src/read.rs

* address review: keep 400-part cap inclusive for rels part

* address review: budget the namespace bytes copied onto kept fragments per part

* fix test initializer: Part gained ns_budget

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-11 16:15:23 -05:00
Dylan M. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> 2406ce46ec chore: bump version to 0.18.1 for the Flatpak update fix (#2048)
Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-11 15:25:10 -05:00
Dylan M. Taylor 60f91f7806 Video decoders: budget repeated-frame and queued output pictures, drop a failed VP9 sample's outputs (#1778)
* Video decoders: budget repeated-frame and queued output pictures, drop a failed VP9 sample's outputs

* address review: size AV1 repeats by the reference format and after layer selection, budget HEVC queued frames at their own size

* address review: charge the larger of the output queue and the in-flight reservation, not both

* address review: count frames held by the old DPB at their own size in the budget check

* address review: charge queued frames outside the DPB and the in-flight set separately
2026-11-11 15:13:36 -05:00
Dylan M. Taylor 2ef5f31bc8 PPTX export: stop adding chart series once a group's caches pass a point budget (#1804)
* PPTX export: stop adding chart series once a group's caches pass a point budget

* address review: size chart exports from the caches each group writes

* address review: cut each chart data cache at a cap far above Excel's 32,000 points per series, so the first series is bounded without failing export
2026-11-11 15:13:23 -05:00
Dylan M. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> c6846f6a75 Photo album: apply the album limits when reopening an album (#1755)
* engine: apply the photo album limits when reopening an album

* address review: refuse an album Update when the editor seed omitted items, instead of deleting them

* Apply review fixes

- crates/engine/src/cmd/insert/album.rs

* address review: refuse an album replace when a slide past the item limit holds other content

* address review: notes and comments on a shapeless slide past the item limit block an album replace

* address review: slide background, transition, animations, colour map and tags count as slide content

---------

Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-11 14:59:47 -05:00
Dylan M. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> a3f2f215b0 Metafile: stop shading on a full canvas, charge nested pictures and clip regions to the budgets (#1777)
* Metafile: stop shading on a full canvas, charge nested pictures and clip regions to the budgets

* address review: count shared tiles once, include text advances, check clip region before keeping an item

* address review: count EMF+ object bytes against the canvas byte budget

* Apply review fixes

- crates/metafile/src/canvas.rs
- crates/metafile/src/tests.rs

* Apply review fixes

- crates/metafile/src/canvas.rs

* address review: count pieces of a continued EMF+ object against the shared byte budget

* Apply review fixes

- crates/metafile/src/canvas.rs

* address review: count the current and saved clips against the shared byte budget

---------

Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-11 14:59:32 -05:00
Dylan M. Taylor 35166b3841 Media: Opus, Ogg, MP4 and Matroska come from craft-libs (#2047)
DeckCraft kept its own Opus decoder (crates/opus) and Ogg demuxer
(crates/ogg), and two crates that only re-exported craft-media
(crates/isobmff, crates/matroska). craft-libs has all four: craft-audio's
opus module and craft-media's ogg, isobmff and matroska modules. The four
crates are removed and crates/media depends on craft-audio and craft-media
directly.

craft-media and craft-audio are pinned at 113f2360, the head of craft-libs
#810, which carries the two Ogg fixes DeckCraft made to its own copy
(51d19baa, 9d36b593). The pin moves to craft-libs main once #810 merges.
craft-pdf, craft-update and craft-ui keep their pins.

One API difference: craft-media's OpusTiming::of saturates on a hostile
granule position instead of returning an error. The end-trim product in
the Ogg Opus decode now saturates as well; a test with a three-channel
file and the largest granule panicked on it without that.

crates/bitstream stays: it also holds the video decoders' memory budget,
which craft-libs does not have.
2026-11-11 14:47:18 -05:00
Dylan M. Taylor b57b4ccfcf PPTX: charge repeated chart decodes and sidecar copies to presentation-wide budgets (#1790)
* PPTX: charge repeated chart decodes and sidecar copies to presentation-wide budgets

* address review: count all chart vectors and relationship strings; reject a chart that crosses the presentation limit

* address review: charge chart part names/content types, roll back rejected capture

* address review: bound chart decode work across rejected and repeated references, cache refused sidecar captures

* address review: keep a chart past the presentation chart limits as its original part and closure, so a save writes it back
2026-11-11 14:33:58 -05:00
Dylan M. Taylor 2fbdcfe5f5 UI: cap live video decoders and bound slide-show grid thumbnail height (#1801)
* UI: cap live video decoders and bound slide-show grid thumbnail height

* address review: retry video feed creation when playback resumes

* address review: reclaim the decoder of ended videos when the feed cap is reached

* test: cover the video decoder cap and its retry

* address review: seeking a video without a decoder retries the bounded decoder allocation
2026-11-11 14:33:44 -05:00
Dylan M. Taylorandautocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com> 98eac5590d Render: bound bevel edge work, shape-gradient flattening and colour-glyph fills (#1786)
* Render: bound bevel edge work, shape-gradient flattening and colour-glyph fills

* address review: skip shape gradient fill when the outline is over the point limit

* address review: keep every contour of a compound bevel or shape gradient at a coarser raster, bound cubic subdivision before flattening

* Apply review fixes

- crates/render/src/pathgrad.rs

* Apply review fixes

- crates/render/src/pathgrad.rs

---------

Co-authored-by: autocraft-bot[bot] <354215646+autocraft-bot[bot]@users.noreply.github.com>
2026-11-11 14:33:29 -05:00
Dylan M. Taylor 46712d0293 Fonts: honor the disabled system fallback for symbol and script family loads (#1840)
* Fonts: honor the disabled system fallback for symbol and script family loads

* address review: no catalog loading for loaded families when system fallback is off

* test: cascades load no installed family when system fallback is off
2026-11-11 14:33:15 -05:00
Dylan M. Taylor e03c22c445 Notes: place bullets in one pass over the galley rows (#1833)
* Notes: place bullets in one pass over the galley rows

* address review: keep zero-character leading rows in row_span_at
2026-11-11 14:33:01 -05:00
Dylan M. Taylorandaiongg 98208a9eda PDF: gather a paragraph's text once, keep note line widths, count handout rules (#1773)
* pdf: gather a paragraph's text once, keep note line widths, count handout rules

* pdf: spell the rule-count check without a negated comparison

* address review: use a narrow tall page in the bounded-work test

* address review: require the huge-paper export to succeed

* Apply review fixes

- crates/pdf/src/lib.rs

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-11 14:32:47 -05:00
Dylan M. Taylor de52b6b766 MCP: bound a batch's entries and the bytes of results it gathers (#1759)
* mcp: bound a batch's entries and the bytes of results it gathers

* address review: charge the escaped batch reply against the budget

* address review: charge batch error entries against the byte budget

* address review: truncate id in batch limit entry

* address review: count the pretty-printed batch reply against the byte cap
2026-11-11 14:32:33 -05:00
Dylan M. Taylor 7c83fa43ac Spelling: check the automation write roots before appending to the user dictionary (#1754)
* engine: check the automation write roots before appending to the user dictionary

* address review: check the original Path for the dictionary write; isolate the confinement test
2026-11-11 14:32:18 -05:00
Dylan M. Taylor 7fe979aa24 Fix Flatpak permissions for update checks and self-updates (#2045)
* fix(flatpak): allow release checks and self-update helpers

* test: keep Flatpak permission parsing within strict Clippy rules
2026-11-11 14:18:59 -05:00
neilcawse 690171665a pptx: preserve SmartArt node text when dgm:cxnLst has no parent-child connections (#1944)
* pptx: preserve SmartArt node text when dgm:cxnLst has no parent-child connections

* fix(pptx): apply unconnected-point SmartArt outline fallback when type="doc" point is absent

* fix(pptx): restrict SmartArt pt_order fallback to empty parOf graphs and preserve cached Picture/Connector children

* fix(pptx): convert default rect geometry to line for connectors in diagram drawings

* docs(pptx): note flat ptLst order fallback on dgm::outline
2026-11-11 18:48:47 +00:00
Dylan M. Taylor 203fa79211 Merge pull request #2041 from storytold/chore/craft-update-6e03f65
Updates: take craft-update from craft-libs main at 6e03f65bf
2026-11-11 12:43:51 -05:00
Dylan M. Taylor 3e2e73aab7 Updates: take craft-update from craft-libs main at 6e03f65bf 2026-11-11 12:43:42 -05:00
Dylan M. Taylor cf0d7fbb14 ci: run Harden-Runner on every job (#2039)
Adds step-security/harden-runner (audit, pinned to v2.22.4 like
craft-common-actions) as the first step of each job that did not have it,
so every workflow in the org's public repos reports its egress to
StepSecurity. Audit, not block: block-mode allow lists come from each
job's observed traffic.
2026-11-11 12:10:27 -05:00
FredApps 121c59d521 Release: DeckCraft v0.18.0 (#2033) 2026-11-11 16:35:45 +01:00
Dylan M. Taylor a55457ee53 Spelling: stop an affix header's count at the end of the file, share aliased dictionaries (#1794)
* Spelling: stop an affix header's count at the end of the file, share aliased dictionaries

* address review: share the first inserted dictionary on a concurrent load
2026-11-11 04:16:05 -05:00
Dylan M. Taylor 7bd141c1c9 xtask: match attribution globs with a table, not backtracking (#1797) 2026-11-11 04:07:19 -05:00
Dylan M. Taylorandaiongg e3e218a8fc Shape edits: edit each target once and read its box without copying it (#1769)
* engine: edit each target shape once and read its box without copying it

* engine: check the fill colour field in the repeated-ids test

* Apply review fixes

- crates/engine/src/tests.rs

* address review: shape.lock skips targets inside a targeted group

* address review: dedupe shape.lock targets before walking groups

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-11 04:00:58 -05:00
Dylan M. Taylor c532ae749b Logging: escape control characters and mark continuation lines in log records (#1791) 2026-11-11 03:52:36 -05:00
Dylan M. Taylor e0f3639e94 Undo: count a slide's notes, comments and tags in the memory budget (#1768)
* engine: count a slide's notes, comments and tags in the undo memory budget

* engine: drive the undo-budget test through slide.rename

* address review: size text cost by the Paragraph and Run struct sizes
2026-11-11 03:44:11 -05:00
Dylan M. Taylor eb10e2b9b3 Metafile: bound clipping work across all subpaths of one item (#1738)
* metafile: bound clipping work across all subpaths of one drawn item

* address review: charge fill clipping as polygons grow, one budget per item

* address review: charge actual clip work instead of projected polygon growth

* address review: share one clip-work allowance between fill and stroke

* address review: share one clip-work budget across fill and stroke, give region intersection its own budget
2026-11-11 03:35:40 -05:00
Dylan M. Taylorandaiongg 92a0437ab6 Media: check an MP4's sample tables where Symphonia will start reading it (#1737)
* media: check an MP4 for oversized sample tables where symphonia will start reading it

* address review: run MP4 table guard only for MP4 or unknown containers

* Apply review fixes

- crates/media/src/probe.rs
- crates/media/src/mp4guard.rs
- crates/media/src/audio.rs

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-11 03:28:15 -05:00
Dylan M. Taylor 4e87f2b850 H.264: bound decoder work from overlapping slices, gaps and queued output (#1706)
* H.264: bound decoder work from overlapping slices, gaps and queued output

- A macroblock that an earlier slice of the same picture already decoded is
  refused, so overlapping slices cannot redo the picture's reconstruction
  once per slice.
- The memory budget check counts pictures waiting in the output queue that
  have left the DPB (one decode call may carry many access units) and the
  DPB's actual size, counting shared buffers once.
- frame_num gap concealment advances the POC state across the skipped run in
  constant time instead of once per missing frame_num.
- Storing a reference picture evicts until the DPB is under capacity, rather
  than evicting one picture and inserting regardless.

* address review: charge pictures already held at their own size in the decoder budget

* address review: keep the h264 output-queue byte total incrementally
2026-11-11 03:17:58 -05:00
Dylan M. Taylorandaiongg 54846750d4 Ink: extend a stroke in place and cap a gesture's samples (#1770)
* engine: extend an ink stroke in place and cap a gesture's samples

* engine: move the ink extension out of drag

* address review: close scribbles at the release point; keep vertices when a capped press adds none

* Apply review fixes

- crates/engine/src/tools/pointer.rs

* raise ink gesture cap to 100000 points

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-10 18:42:24 -05:00
Dylan M. Taylorandaiongg 1e9c392f67 Spelling: remember failed compound tails (#1835)
* Spelling: remember failed compound tails

* Apply review fixes

- crates/engine/src/spell.rs

* Apply review fixes

- crates/engine/src/spell.rs

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-10 17:54:53 -05:00
Dylan M. Taylorandaiongg c3fb184e9d engine: index font names and embedded faces instead of rescanning (#1749)
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-10 17:54:22 -05:00
Dylan M. Taylor 0623b43032 Merge pull request #1985 from dotCooCoo/chore/craft-update-1b34eba41
Updates: take craft-update from craft-libs main at 1b34eba41
2026-11-08 22:55:36 -05:00
dotCooCoo 5ee82b4e45 Updates: take craft-update from craft-libs main at 1b34eba41 2026-11-08 19:43:26 -08:00
Dylan M. Taylor 61423ea35b Merge pull request #1980 from dotCooCoo/chore/craft-update-updater-fixes
Updates: the updater reports installing, ready, installed and failed, and installs at exit
2026-11-08 22:12:17 -05:00
aiongg c4674f24fb Merge pull request #1982 from Silvertree2010/fix/h13151-polynomial-trendline-fit-fails-f
Make polynomial trendline fits independent of x scale and offset
2026-11-08 15:46:38 -05:00
NetterSilverandClaude Opus 5.5 4ba9a1a5d9 Make polynomial trendline fits independent of x scale and offset
Found while testing crates/model/src/trendline.rs:~419 (solve, used by polyfit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 21:33:22 +01:00
dotCooCoo 3504a9da42 xtask: pdf-deps expects craft-pdf and krilla from the moved craft-libs pin
The pdf-deps check compared craft-pdf and krilla against storytold/craft-libs
at c5a1cbfb, so the gate stopped at that step once the pin moved to
dotCooCoo/craft-libs c7c84230. The expected repository and revision now
match the pin in Cargo.toml.
2026-11-08 12:11:03 -08:00
dotCooCoo ccab980657 Updates: the updater reports installing, ready, installed and failed, and installs at exit
craft-media, craft-pdf, craft-ui, craft-update and krilla move from craft-libs
c5a1cbfb to c7c84230, which carries storytold/craft-libs#687 and #696.

The check result window now shows whenever the updater's status is not
idle: after Help > Check for Updates, and at the first start after an
update, to say that it installed or why it failed. Restart to Update in
that window asks the app to close once nothing is unsaved. on_exit starts
the helper of a ready update, since on macOS it ends the process before
the app is dropped.
2026-11-08 11:30:36 -08:00
aiongg 48e2203123 Merge pull request #1969 from Silvertree2010/fix/h11403-sentence-case-capitalizes-the-wo
Don't capitalize the word after a decimal point in sentence case
2026-11-08 03:35:42 -05:00
NetterSilverandClaude Haiku 5.5 76bd26cca0 Don't capitalize the word after a decimal point in sentence case
Found while testing crates/model/src/edit.rs:~505 (change_case_parts, sentence branch)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 08:26:05 +00:00
Nasim 02fbb6a976 Notes: pin that Paste reaches the focused speaker-notes field, bare and inside the ⌘V batch (refs #1855) (#1856) 2026-11-08 01:59:22 -05:00
aiongg c16db2ef3e Merge pull request #1943 from neilcawse/fix/pptx-body-and-group-3d
pptx: preserve 3-D scene and extrusion XML on text bodies and groups
2026-11-08 01:18:16 -05:00
aiongg 809c44654d Apply review fixes
- crates/pptx/src/write/shapes.rs
2026-11-08 05:59:15 +00:00
aiongg 220b52dfdb Apply review fixes
- docs/file-format-parity.md
2026-11-08 05:43:41 +00:00
Neil Cawse 424f575ca5 fix(pptx): bound and validate body/group raw3d XML on read/write and expand 3-D tests 2026-11-08 05:26:33 +00:00
Neil Cawse aefd54d28e pptx: preserve 3-D scene and extrusion XML on text bodies and groups 2026-11-08 05:24:59 +00:00
Nasim 25809faf1a Accessibility: name ribbon drop-downs by purpose and expose the shown text as their value (#1956) (#1963) 2026-11-08 00:18:14 -05:00
aiongg fc35ad67c6 Merge pull request #1950 from Silvertree2010/fix/h9583-format-paragraph-silently-left-a
Fix format.paragraph silently left-aligning known align names
2026-11-07 15:10:55 -05:00
NetterSilverandClaude Haiku 5.5 57780f9e61 Fix format.paragraph silently left-aligning known align names
Found while testing crates/engine/src/cmd/format/paragraph.rs:123-129

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:00:33 +00:00
FredApps 0627fb2589 Merge pull request #1932 from helenkwok/web/page-api
deckcraft-web: window.deckcraft, the page command API from craft-web-api
2026-11-07 15:37:54 +01:00
aiongg 170c5591b5 Merge pull request #1934 from Silvertree2010/fix/h7742-shape-adjust-with-an-invalid-neg
Reject invalid index in shape.adjust and shape.quickStyle
2026-11-07 03:34:42 -05:00
NetterSilverandClaude Haiku 5.5 a883d6b2d9 Reject invalid index in shape.adjust and shape.quickStyle
Found while testing crates/engine/src/cmd/shape/appearance.rs:8 (adjust, index via usize_param); same pattern in quick_style at :559

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 08:14:47 +00:00
Helen Kwok 4b1e50a6b0 deckcraft-web: put each Cargo.toml comment above its own dependency, and say a call the app already took still finishes after a timeout 2026-11-07 10:28:04 +08:00
Helen Kwok c7919102ac deckcraft-web: window.deckcraft, the page command API from craft-web-api
The web shell queues the page's calls as control requests (the channel the desktop control server uses) and answers them on the next frame. The API is installed in every mode; opening, saving, the file name and the unsaved flag stay with the host messages.
2026-11-07 08:05:09 +08:00
FredApps 159ed07eb3 Merge pull request #1918 from storytold/house-rules
Copy the shared house rules into AGENTS.md
2026-11-06 15:00:00 +01:00
FredApps de9e79a445 docs: preserve local guidance in house-rules rollout 2026-11-06 12:36:46 +01:00
FredApps d7343553b3 Copy the shared house rules into AGENTS.md.
The blocks come from craft-contrib at dde09ea31e2e457f5b4653a6e76f7cdf55944bdf. CLAUDE.md imports AGENTS.md. The house-rules workflow checks the copy.
2026-11-06 11:50:01 +01:00
Luis 9a95c3a639 Release: refresh contributor credits for each release (#1889) (#1890)
About > Contributors still showed the snapshot taken for v0.4.0, because
nothing regenerated contributors/contributors.json before a release.
Opt in to the shared refresh from craft-common-actions: a read-only
credits job regenerates the snapshot for the release commit, and the
shared release workflow installs it before every source build. The
snapshot is not committed back.
2026-11-05 17:23:44 +01:00
FredApps daeedd5d19 Merge pull request #1895 from helenkwok/web/host-crate
deckcraft-web: host messages through craft-web-host, and page host mode
2026-11-05 14:11:06 +01:00
FredAppsandCursor a2c1633fe3 fix(web): refuse a host file of exactly 2 GiB
wasm32 cannot allocate a Vec of 2 GiB (isize::MAX is one byte less), so
the open check that only rejected a larger file trapped on that boundary.
Refuse 2 GiB as well, and say so in the failure the host receives.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 13:59:10 +01:00
aiongg bfdcc5cdfa Apply review fixes
- packaging/web/README.md
- apps/deckcraft-web/src/host.rs
2026-11-05 11:55:08 +00:00
Michael Müllerandaiongg 73de9900a7 Show: Presenter View only with a second display (#1156)
* Show: Presenter View only with a second display

With one display the presenter window opened on the same screen as the show. Under Hyprland's
focus-follows-mouse, whichever window the pointer was over took full screen, and the hidden one
stalled every frame: the show lagged, dropped key presses or froze.

One display now shows only the slides, as in PowerPoint. The host counts displays through winit's
monitor list when a show starts; hosts that can't tell keep Presenter View.

* Show: count displays by MonitorHandle

Wayland lists one output twice under the same wl_output id, so comparing handles counts it once, and two separate displays with the same name and geometry still count as two.

* Show: docs for Presenter View with a second display

slideshow-parity and hardware-parity note that on Linux one display shows only the slides, with status lines and revision history; ROADMAP progress log. display_count collects into a BTreeSet (MonitorHandle's Ord matches its Eq).

* Show: docs state the one-display rule exactly

Presenter View stays off when winit lists exactly one display; if it lists none, the setting is kept. The host callback covers Unix other than macOS, so the docs say Linux and the BSDs.

* Apply review fixes

- docs/slideshow-parity.md

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-05 12:51:16 +01:00
aiongg b225bb16a1 Apply review fixes
- apps/deckcraft-web/src/host.rs
2026-11-05 11:49:17 +00:00
Helen Kwok 9299113268 deckcraft-web: speak the host messages through craft-web-host, and add page host mode
The transports, the message types and the origin checks come from the shared crate (storytold/craft-libs#420). `data-host="page"` on the canvas now hosts DeckCraft in a page's own document, as in the other apps; the parent bridge keeps its behaviour.
2026-11-05 17:51:01 +08:00
FredApps 2b6d27d525 Merge pull request #1658 from SSchott/copy-the-fix
Fix Copying Images, Ctrl-C/Ctrl-V and within Deckcraft
2026-11-05 05:59:48 +01:00
FredApps 9998ee5e07 Merge pull request #1809 from jimprince/web-host-bridge
Web: open and save through a framing page (?host=parent)
2026-11-05 05:59:44 +01:00
FredApps 9e8aa81298 Merge remote-tracking branch 'origin/main' into copy-the-fix 2026-11-05 05:49:57 +01:00
FredApps 6b5faf6d51 Merge remote-tracking branch 'origin/main' into web-host-bridge 2026-11-05 05:49:57 +01:00
FredApps 0b5cd0f2d6 Merge pull request #1876 from zberry/connector-glue-in-groups
shape.connect glues to a shape inside a group, and the connector follows it
2026-11-05 05:43:49 +01:00
FredApps 4f171e8f78 Merge remote-tracking branch 'origin/main' into copy-the-fix 2026-11-05 05:41:44 +01:00
FredApps edeb99b00e Merge remote-tracking branch 'origin/main' into web-host-bridge 2026-11-05 05:41:44 +01:00
FredApps 29162e66ad Merge main into connector-glue-in-groups (keep both tests) 2026-11-05 05:32:34 +01:00
FredApps 8435cc301f Merge pull request #1886 from sbjwin/fix/thumbnail-grayscale
UI: follow Grayscale view mode in slide thumbnails
2026-11-05 05:31:42 +01:00
FredApps 75885b49de Merge pull request #1881 from zberry/smartart-no-flip
Flip is refused on a SmartArt graphic instead of turning it into a plain group
2026-11-05 05:31:38 +01:00
FredApps f7b728d0c2 Merge remote-tracking branch 'origin/main' into web-host-bridge 2026-11-05 05:25:08 +01:00
FredApps d82aaddab4 Merge remote-tracking branch 'origin/main' into copy-the-fix 2026-11-05 05:24:59 +01:00
FredApps 8f26681a1f Merge main into connector-glue-in-groups (keep both tests) 2026-11-05 05:21:16 +01:00
FredApps f2356fcf63 Merge main into flip-smartart (keep both tests) 2026-11-05 05:19:55 +01:00
FredApps 60132037b2 Merge branch 'main' into web-host-bridge
Keep both ROADMAP progress entries: Ogg Opus duration from main and the
web ?host=parent bridge from this branch.
2026-11-05 05:19:47 +01:00
FredApps ac09977c12 Merge pull request #1875 from zberry/table-cells-refused
Table cell commands refuse a malformed `cells` list instead of dropping entries
2026-11-05 05:19:11 +01:00
FredApps 480cbfd4ef Textures::thumb: document the stale thumbnail returned over budget 2026-11-05 05:18:38 +01:00
FredApps afc7f765c9 shape.connect: refuse a grouped non-line id as not a line 2026-11-05 05:18:29 +01:00
aiongg d9186c7ce1 Apply review fixes
- crates/engine/src/cmd/shape/size.rs
2026-11-05 04:04:02 +00:00
aiongg 75744dd67d Apply review fixes
- crates/engine/src/tools/drag.rs
2026-11-05 04:02:42 +00:00
sbjwin ebd306acc1 UI: request repaint on stale thumbnail return to finish grayscale refresh 2026-11-05 12:51:29 +09:00
aiongg 161af3b7d5 Merge main into smartart-no-flip 2026-11-05 03:49:15 +00:00
aiongg 4e4d98e69d Merge main into table-cells-refused 2026-11-05 03:49:09 +00:00
aiongg a52b2d348f Merge main into connector-glue-in-groups 2026-11-05 03:45:22 +00:00
aiongg acd19ff4ac Merge pull request #1879 from zberry/outline-title-and-body
Outlines leave footer, date and slide-number placeholders out: export, RTF and the outline view
2026-11-04 22:38:05 -05:00
sbjwin b06d60b8ed UI: follow Grayscale view mode in slide thumbnails
In PowerPoint, turning on Grayscale view mode turns both the active
slide canvas and the slide thumbnail pane to grayscale, while Print
retains the original full color for output.

Previously, DeckCraft only passed `app.ui.grayscale` to the main canvas
texture renderer (`Textures::slide`), while thumbnail rendering
(`Textures::thumb`) hardcoded `false` and lacked `grayscale` in its
cache key. As a result, when Grayscale mode was enabled, the canvas
rendered in grayscale but the thumbnail pane remained in full color.

- Update `Textures::thumb` to accept `grayscale: bool` and include it in
  the cache key (`extra: grayscale as u64`).
- Pass `app.ui.grayscale` to `Textures::thumb` in the thumbnail pane
  (`panels/thumbs.rs`), Slide Sorter & Notes Page (`sorter.rs`), and
  Notes Master view (`canvas.rs`).
- Keep Print preview (`backstage/print.rs`) in full color (`false`) to
  match the actual exported PDF print output.
- Add regression test `to_color_image_turns_colors_to_grayscale_when_flag_is_set`.
2026-11-05 12:35:59 +09:00
FredAppsandClaude Opus 5.5 a2d9e71b5e About: credits tables fit without side-scrolling and their columns resize (#1806)
* About: credits tables fit without side-scrolling and their columns resize

The Contributors table and the Models table were egui Grids inside a
two-way scroll area: they opened wider than the dialog, so they scrolled
sideways, and Company and Model sat almost touching. Both are now
egui_extras tables. Each column opens at the width of its header and widest
cell, the spare width goes to the text columns (Name; Company and Model),
and every column edge can be dragged. Widening a column grows the table
like a spreadsheet, and only then does it scroll sideways; the header stays
put while the rows scroll. Counts and dates are right-aligned and every
cell is vertically centred.

The sorted column stays highlighted and its direction shows on the
toolbar's sort-order button, so headers no longer reserve room for an
arrow. The opening widths come from fit_widths, a pure function with unit
tests that a fitted table fills its room exactly and that only the text
columns give way when room is short.

The About dialog opens 820 points wide, so every column fits at its natural width.

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

* Let every credits column be dragged, and refit changed tables

The last column was egui's remainder column, which has no drag handle, so
it could only be resized from the edge before it. It is now an ordinary
column with its own handle.

A table keeps its column widths, so switching to a longer name left the
Name column at its old width and clipped the text. The table now goes
back to the opening fit whenever that fit changes: a longer widest cell
or a resized window. Dragged widths are dropped at that point.

fit_widths could overflow the room when one column hit the minimum,
because the other columns did not take up its share. Columns now give
way in proportion to their width above the minimum.

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

* Refit credits tables on what they measure, not the fitted widths

Comparing the fitted widths missed the case it was meant for. Name is the
only growing column, so a longer name takes its extra width from the
spare room and the fit comes out the same: a dragged Name width stayed,
clipping the longer name.

It could also drop dragged widths by accident. The fit comes from the
available width inside the scroll area, which scrolling can nudge, so
scrolling a widened table could reset it.

The table now compares the natural widths and the clip rect's width,
which scrolling does not move.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:33:51 +01:00
Zach BerryandClaude Opus 5.5 fe35df948c Flip is refused on a SmartArt graphic instead of turning it into a plain group
PowerPoint greys Flip out for a SmartArt graphic (SmartArt Design ▸ Right to
Left is the way to mirror it), also inside a group, as ungrouping would hand
the flip to the graphic. Before, a flip turned it into a plain group that the
smartArt.* commands then refused. arrange.flipHorizontal and flipVertical
reach the check through shape.flip. Split out of the SmartArt PR at review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:09:40 -06:00
Zach BerryandClaude Opus 5.5 ede154d6be Outlines leave footer, date and slide-number placeholders out: export, RTF and the outline view
An agent run exporting a deck as an outline got the footer text and the
slide-number field (‹#›) as body lines under each title. PowerPoint's
outline view shows title and body placeholders only, so the text outline,
the RTF outline (Save As ▸ Outline/RTF) and the outline view now skip the
footer-row placeholders as they already skipped the title. Split out of the
frame export PR at review, which asked for all three together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:07:59 -06:00
FredApps 6431460e80 Merge pull request #1872 from FredApps/fix/1866-web-presenter-all-slides
Show: All slides opens in the web Presenter View, as on desktop
2026-11-05 03:04:06 +01:00
Zach BerryandClaude Opus 5.5 af9f522c6a shape.connect glues to a shape inside a group, and the connector follows the group
An agent run found that connecting to a grouped shape answered ok but left
the connector's end unset: the sites of a group member were computed in the
group's child space, and rerouting only looked at the slide's top-level
shapes, so the glue was dropped at once. Both now locate the shape anywhere
in the tree with the affine onto the slide, as PowerPoint glues to grouped
shapes. shape.sites answers in the same slide space. A line inside a group is
refused as `id`: its ends would land in the group's space, and rerouting
does not follow grouped connectors.

Split out of the connector default sites PR at review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:48:16 -06:00
FredApps efd26de81d Show: clear the web presenter grid in hidden logic, let Enter and Space pick a focused thumbnail (#1866) 2026-11-05 02:48:01 +01:00
Zach BerryandClaude Opus 5.5 7c47d0a9ac Table cell commands refuse a malformed cells list instead of dropping entries
`cells` must be a list of exact [row, col] pairs inside the table; an entry
that is not one, a cell past the table, or more than 100000 entries is refused
and names the command, instead of being dropped or read as absent (which acted
on the selection or the whole table). Borders use checked arithmetic for the
neighbour cell. Split out of the 0-based index PR at review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:40:18 -06:00
FredApps 884e3d3353 Show: keep web All slides messages small, clear the grid when the popup closes, show it on the end screen (#1866) 2026-11-05 02:37:37 +01:00
FredApps f49ff09b8a Show: All slides opens in the web Presenter View, as on desktop (#1866) 2026-11-05 02:25:54 +01:00
aiongg 52f87aca13 Merge pull request #1474 from zberry/pptx-show-media-controls
PPTX: Show media controls survives a save and reopen
2026-11-04 19:17:57 -05:00
aiongg dfee4c1e88 Merge pull request #1870 from Silvertree2010/fix/1864-next-and-previous-not-working-in
Make Previous/Next work in the web presenter popup
2026-11-04 19:03:38 -05:00
NetterSilverandClaude Opus 5.5 f321416b34 Make Previous/Next work in the web presenter popup
Fixes #1864

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:52:05 +01:00
aiongg 41f07a3c4e Merge main into pptx-show-media-controls 2026-11-04 23:51:52 +00:00
.CooCoo 85de6ca5d7 Shape Fill: the gradient help says which kinds rotateWithShape keeps upright (#1843)
With rotateWithShape false, the renderer lays linear and radial gradients
out upright on the slide, while rectangular and path gradients still turn
with the shape. The shape.fill params doc now says so.
2026-11-04 18:11:29 -05:00
Harrison Booth fc18a78e65 comment: reject malformed navigation indices (#1547) 2026-11-04 18:09:50 -05:00
Dylan M. Taylor 31abd2d819 Pass the Linux signing key to the release workflow (#1868) 2026-11-04 17:45:40 -05:00
Stephan Schott-VerdugoandClaude Opus 5.5 070f12cc9c Keep Source Formatting resolves colours as the renderer does
Review follow-up on #1658 (CodeRabbit), both from storing the slide's
mapped scheme in 30ccc38e:

- Copying in Slide Master view used the current slide's colour map. Use
  `ctx_for`, so the theme is that of what is being edited.
- `ColorScheme` shares an entry between `dk1` and `tx1`, so a mapped scheme
  changed direct `dk1` colours too: a dark fill pasted white. `SourceTheme`
  keeps the theme's colours and the map apart, and `bake_theme` maps only
  `tx1`, `bg1`, `tx2` and `bg2`, as `Ctx::color` does.

The browser image test now checks that non-picture pastes keep their text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:23:35 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 46a15a4684 Merge branch 'main' into copy-the-fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:19:46 +01:00
aiongg 144fd28f69 Merge pull request #1192 from MarekSpodBiedry/issue-1153
Use evaluated field values in the placed text so the PDF text layer shows the slide number (#1153)
2026-11-04 16:15:06 -05:00
Stephan Schott-VerdugoandClaude Opus 5.5 30ccc38e90 Keep Source Formatting uses the slide's colour map
Review follow-up on #1658 (CodeRabbit): `source_theme` stored the master's
scheme, so a slide or layout that overrides the colour map had its colours
fixed through the wrong map. Detaching made this reach a placeholder's
inherited colours: a title drawn white under an override pasted black.
Store the scheme the slide draws with. Paste as Picture reads the same
value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:02:22 +01:00
aiongg 65ddd8ad51 Apply review fixes
- crates/render/src/placed.rs
2026-11-04 20:55:43 +00:00
Stephan Schott-VerdugoandClaude Opus 5.5 d6cc51e07a Alt+drag detaches placeholders; Slide Master view pastes them as placeholders
Review follow-up on #1658:

- The drag duplicate cleared `ph` without detaching, so a title or body
  dragged off with Alt lost its size and bullets. Copy, Duplicate and the
  drag now share `detach_on_slide`.
- Detaching at copy time turned a slide placeholder pasted in Slide Master
  view into a regular shape. The clipboard keeps the originals, and a paste
  there takes them, so it stays a placeholder as before this PR. Keep
  Source Formatting bakes them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:43:33 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 077cd36e9f Merge branch 'main' into copy-the-fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:38:04 +01:00
Mex 9d36b5931c Ogg: include recovered tail packets in Opus duration (#1862)
* Ogg: include recovered tail packets in Opus duration

Keep the existing missing-granule recovery cursor in step with the packet ends it assigns. Add a regression for a partially and entirely unanchored stream; existing valid end-trimming coverage remains unchanged.

Validation: rustc --edition=2024 --test crates/ogg/src/lib.rs: all 12 tests pass. Scoped rustfmt check passes. Full cargo --offline xtask ci is blocked before build by the uncached pinned egui checkout.

* Ogg: record recovery duration correction
2026-11-04 15:15:59 -05:00
aiongg c252a681a0 Merge pull request #1454 from Silvertree2010/fix/h3020-rtf-outline-import-drops-the-res
Keep whole character after a backslash in RTF outline import
2026-11-04 14:43:02 -05:00
Dylan M. Taylor 80f784d210 Fonts: ignore variation tags the font has no axis for (#1795) 2026-11-04 14:36:13 -05:00
Dylan M. Taylor 07dc2210a1 Repair: bound the notes page size like a slide (#1832) 2026-11-04 14:09:46 -05:00
Dylan M. Taylor 51d19baa0c Ogg, xtask: check packet ranges against the source first; stats skips directory links (#1796) 2026-11-04 13:56:48 -05:00
MarekSpodBiedry 50187449c2 Render tests: import place_slide_with so the date test compiles (#1153) 2026-11-04 07:52:46 +01:00
MarekSpodBiedry b8de82e2cc Merge remote-tracking branch 'origin/main' into review-1192 2026-11-04 07:51:04 +01:00
bradandClaude Opus 5.5 6eaae2bd8c Web: only the page's first file replaces the start deck; close Save As after a same-name save
A file the page sent without a name opens untitled and unchanged, like the
empty start presentation, so the page's next file closed it. Host mode now
records the start presentation (start_for_host) and only the first file
that opens takes its place, as packaging/web/README.md says.

Save As > Save under the presentation's own name changed neither the path
nor the dirty flag, so the Save As page stayed open and a second click sent
the file again. The page now also compares the revision, which every save
bumps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:26:50 -06:00
Dylan M. Taylor 23ac4f7efd Fonts: bound font-controlled work in enumeration, outlines and carets (#1701)
* Fonts: bound font-controlled work in enumeration, outlines and carets

An embedded font could make each lookup do far more than real fonts need:
- enumerating a collection read up to 256 faces times 1024 named instances,
  each then built with its own shaper; now 4096 per file across all faces,
  with a set for the duplicate-style check instead of a scan;
- composite and VARC glyphs could expand one dense outline without limit,
  and the outline cache only counted entries; outlines now stop at 32768
  path elements (drawing nothing past it) and the cache is also bounded by
  the elements it holds;
- the 64-caret limit applied after filtering, so a ligature lookup could
  read every caret record; it now applies to the records read.

* address review: bound thread-local glyph cache by path elements
2026-11-03 20:39:57 -05:00
Dylan M. Taylor 69c91bb265 Render: index table style, skip list, chart point fills and waterfall bars; count each dash (#1774)
* render: look up a table's style once, keep the skip list as a set, count each dash

* render: index chart point fills, scatter x presence and waterfall bars once per chart
2026-11-03 20:39:36 -05:00
Dylan M. Taylor a790fdbc20 Render: find a glyph's line by bisection for gradient and WordArt text (#1783) 2026-11-03 20:39:14 -05:00
Dylan M. Taylor 9d59fa96d3 Text, morph: share a run's gradient between glyph runs, match morph tokens by text (#1784) 2026-11-03 20:38:53 -05:00
Dylan M. Taylor 2dfb07f2c4 Thumbnails: index section starts once (#1800) 2026-11-03 20:38:31 -05:00
Dylan M. Taylor f5fbadde63 Render: evict the least recently used animation instead of clearing the GIF cache (#1802) 2026-11-03 20:38:10 -05:00
Dylan M. Taylor 996bb42ff5 pptx: index relationships by id on read and by target on write (#1739) 2026-11-03 20:12:47 -05:00
Dylan M. Taylor 3c3d504eb1 PPTX: read a notes or comments part for the first slide that names it (#1741)
* pptx: read a notes or comments part for the first slide that names it

* address review: keep modern-comments precedence for already-owned parts
2026-11-03 20:12:24 -05:00
Dylan M. Taylor f5511d93b6 Compare: index slide, shape and media lookups and compare each picture pair once (#1752)
* engine: index review.compare lookups and compare each picture pair once

* engine: keep the compare test's inserted slides empty
2026-11-03 20:12:03 -05:00
Dylan M. Taylor c6b7de43bc engine: use sets for slide-range expansion, added slide selection and repeated-word offsets (#1756) 2026-11-03 20:05:53 -05:00
Dylan M. Taylor 65d5c28512 Text: sort a paragraph's tab stops once and find the next by binary search (#1757)
* text: sort a paragraph's tab stops once and find the next by binary search

* address review: skip the tab-stop lookup for a NaN position
2026-11-03 20:05:32 -05:00
Dylan M. Taylor d2a8be197d geom: measure each follow band once when mapping warped lines (#1758) 2026-11-03 20:05:11 -05:00
Dylan M. Taylor 872cdbf331 pptx: take nested math text once and check an object's part for relationships once (#1762) 2026-11-03 20:04:49 -05:00
Dylan M. Taylor 0233d29064 Spelling: bound a word's language and index results by offset (#1767)
* engine: bound a word's language and index spelling results by offset

* engine: pass the test run's text by value
2026-11-03 19:59:51 -05:00
Dylan M. Taylor a72a5167d0 Morph: pick the next curve to split from a heap, not a full scan (#1782) 2026-11-03 19:59:29 -05:00
bradandClaude Opus 5.5 d5471bccf0 Web: open and save through a framing page (?host=parent)
A same-origin page that frames DeckCraft's web build with ?host=parent
sends a file with deckcraft:open and gets every save back as
deckcraft:save {name, saveAs, bytes} instead of a download, with
deckcraft:ready, deckcraft:dirty and deckcraft:failed, as GridCraft,
WordCraft and PdfCraft do. Without ?host=parent nothing changes.

Save follows the desktop app. Every file from the page opens through
file.adopt like File > Open on the desktop; a presentation sent with a
presentation name keeps that name as its file, so Save (Ctrl+S, the
File tab, the Close prompt or the command palette) writes it back under
that name and format with saveAs: false. Anything else (no name, an
outline, a damaged file opened as a repaired copy, a dropped file) is
untitled, and its first Save is a new <title>.deckcraft with saveAs:
true, as is Save As. Exports and Print are copies (saveAs: false).

Host mode starts with an empty presentation that gives way to the first
file while untouched, &author= signs comments, and the URL flags are
read as whole parameters so a free-text author can't set ?show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:58:29 -06:00
Dylan M. Taylor b7d57bacab Text edit: splice inserted paragraphs once and paste runs without rescanning (#1787) 2026-11-03 19:54:06 -05:00
Dylan M. Taylor 42108d0ee7 PPTX export: index animation targets, cache build roles, serialize run properties once per text run (#1788)
* PPTX export: index animation targets, cache a paragraph's build roles, serialize run properties once per text run

* address review: warn once when a by-paragraph build is truncated
2026-11-03 19:53:44 -05:00
Dylan M. Taylor 0107186cee Engine: check the temporary file's path against the write root (#1792) 2026-11-03 19:46:09 -05:00
Dylan M. Taylor 54e24ab868 Review: replace each whole word in one pass (#1793) 2026-11-03 19:36:05 -05:00
Dylan M. Taylor 19dceffd0b pptx: index media, layouts, sections and table styles on export (#1761) 2026-11-03 14:38:07 -05:00
Dylan M. Taylor 1eec8b266c model: group box charts and stack sums in one pass, share hierarchy labels, cap number formats (#1760) 2026-11-03 14:14:24 -05:00
Dylan M. Taylor 32f069eb84 AV1, HEVC: count repeated-frame outputs incrementally and drop inherited entry-point storage (#1763)
* av1: count repeated-frame outputs as they are added

* hevc: give each slice segment its own entry-point list instead of keeping the copied one's allocation
2026-11-03 13:44:00 -05:00
Brandon ThomasandClaude Opus 5.5 aa13eeb45e Integration: community PR wave 10 (one bodyPr attribute chain, reuse imports, doc headers folded)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:54:48 -05:00
Brandon Thomas 50e02490b3 Merge pull request #1720
# Conflicts:
#	crates/engine/src/cmd/reuse.rs
2026-11-03 08:53:01 -05:00
Brandon Thomas 907ebd2de0 Merge pull request #1710
# Conflicts:
#	crates/model/src/resolve.rs
#	crates/model/src/table.rs
#	crates/model/src/text.rs
#	crates/pptx/src/read/dml.rs
#	crates/pptx/src/read/shapes.rs
#	crates/pptx/src/write/dml.rs
#	crates/pptx/src/write/shapes.rs
#	crates/pptx/tests/it/roundtrip.rs
2026-11-03 08:53:00 -05:00
Brandon Thomas ed77cc9eaa Merge pull request #1711
# Conflicts:
#	crates/pptx/tests/it/roundtrip.rs
2026-11-03 08:53:00 -05:00
Brandon Thomas 928aa9cc16 Merge pull request #1713
# Conflicts:
#	crates/pptx/tests/it/roundtrip.rs
2026-11-03 08:53:00 -05:00
Brandon Thomas 5cb154ce9d Merge pull request #1712
# Conflicts:
#	crates/pptx/tests/it/roundtrip.rs
2026-11-03 08:53:00 -05:00
Brandon Thomas ef55104799 Merge pull request #1719 2026-11-03 08:52:49 -05:00
Brandon Thomas 3f29dccc0a Merge pull request #1717 2026-11-03 08:52:49 -05:00
Brandon Thomas 160c923caf Merge pull request #1716 2026-11-03 08:52:49 -05:00
Brandon Thomas 9f01f2ebdb Merge pull request #1707 2026-11-03 08:52:49 -05:00
Brandon Thomas d365e0b9de Merge pull request #1751 2026-11-03 08:52:49 -05:00
Brandon Thomas 1d181017f6 Merge pull request #1723 2026-11-03 08:52:49 -05:00
Brandon Thomas 996ca4dd65 Merge pull request #1722 2026-11-03 08:52:49 -05:00
Brandon Thomas 6c1170af62 Merge pull request #1721 2026-11-03 08:52:49 -05:00
Brandon Thomas ed62aafbe6 Merge pull request #1709 2026-11-03 08:52:49 -05:00
Brandon Thomas 9f68e8e2cc Reuse: a non-finite size ratio leaves content unscaled; cell text scaling tested; doc history kept 2026-11-03 08:52:48 -05:00
Brandon Thomas a6e2b64875 Reuse: a remapped table style's own XML carries its new id; checklist row name kept 2026-11-03 08:51:44 -05:00
Brandon Thomas c7b91ff2ec PPTX: reflection angles, skews and start position are written inside the schema's ranges 2026-11-03 08:50:28 -05:00
Brandon Thomas 6546136a42 PPTX timing: a sorted click group keeps its click on the effect that now plays first 2026-11-03 08:49:18 -05:00
Brandon Thomas 67c1d78948 PPTX: horzOverflow never reads or writes ellipsis (vertical only in ST_TextHorzOverflowType) 2026-11-03 08:47:09 -05:00
Brandon Thomas 4df4ea6c9f Text: Thai distributed paragraphs still stretch like justified ones 2026-11-03 08:41:57 -05:00
Naseq 751ac26d28 fix(pptx): place effects by their parent time node delays (fixes #1744) 2026-11-03 17:57:57 +04:30
Dylan M. Taylor 82ec8137dc Format: bound native load work for the manifest, failed media reads and layout checks (#1704)
- presentation.json gets its own 256 MiB inflation limit instead of the 2 GiB
  per-entry allowance, and is parsed in place: only a file that needs its
  null-number repair is copied.
- A media member that fails decoding or its CRC is charged for the bytes it
  inflated, so later members cannot reuse the shared media budget.
- repair() and Presentation::validate() check slide layouts against a set
  built once instead of scanning every layout per slide.
2026-11-03 07:39:27 -05:00
Stephan Schott-Verdugo ba3d74dfb5 Merge branch 'main' into copy-the-fix 2026-11-03 09:10:31 +01:00
Harrison Booth b61c5f11bd D716: preserve reflection transforms on save 2026-11-02 22:45:51 -06:00
Harrison Booth a0956fee13 Preserve alternate run language 2026-11-02 22:42:25 -06:00
Harrison Booth bf60dbb773 Fit reused slide content to destination size 2026-11-02 22:41:55 -06:00
Harrison Booth 328b4ed274 Preserve RTL table direction 2026-11-02 22:41:44 -06:00
Harrison Booth 1e5b78de37 D702: prune deleted slides from custom shows 2026-11-02 22:32:41 -06:00
Harrison Booth 73bd231589 D709: remove dangling animations from merged shapes 2026-11-02 22:32:19 -06:00
Harrison Booth 63531e2a6a Merge remote-tracking branch 'origin/main' into dc-build-d705
# Conflicts:
#	ROADMAP.md
#	docs/parity-checklist.md
2026-11-02 22:21:16 -06:00
Harrison Booth 6cdbee50a3 Reuse custom table styles with slides 2026-11-02 22:20:52 -06:00
Brandon Thomas 0c00f22403 Merge pull request #1694
Comments: the pane keeps its width and resizes again (fixes #1431)
2026-11-02 23:07:49 -05:00
Brandon Thomas 3c8dc6bc98 Merge pull request #1693
Render: tiled picture fills keep their size in scaled groups and upright tiles
2026-11-02 23:07:49 -05:00
Brandon Thomas 6ab485a096 Merge pull request #1679
Transitions: play transition preview from previous slide when Preview is clicked (#1654)
2026-11-02 23:07:48 -05:00
Brandon Thomas 597b9488f5 Merge pull request #1579
ui: label animation effect options
2026-11-02 23:07:48 -05:00
Brandon Thomas 4eb9098a7c Merge pull request #1588
Preserve bullet sizes authored in points
2026-11-02 23:07:47 -05:00
Brandon Thomas df4bb8b84a Merge pull request #1698
Reject png/jpeg targets in file.save instead of writing zip bytes

# Conflicts:
#	crates/engine/src/tests.rs
2026-11-02 23:07:47 -05:00
Brandon Thomas f66bed88ac Merge pull request #1686
Use a short temp file name in write_file for long file names
2026-11-02 23:07:46 -05:00
Brandon Thomas ee64e93c4a Merge pull request #1682
Reject invalid master and layout indices in view.slideMaster
2026-11-02 23:07:45 -05:00
Brandon Thomas f8128f9107 Merge pull request #1680
Reject unsupported handout perPage values in file.export
2026-11-02 23:07:45 -05:00
Brandon ThomasandClaude Opus 5.5 97050a353b Bullets: an own bullet size still clears an inherited follow-text flag (as #1589 did)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:07:34 -05:00
Brandon ThomasandClaude Opus 5.5 fb3b857f53 file.save: an explicit jpg format is refused too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:07:29 -05:00
Brandon ThomasandClaude Opus 5.5 ba3b57c756 file.export: a perPage that isn't a count is refused, not read as 6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:07:24 -05:00
Harrison Booth e4a0c2971b Keep Thai distributed text rendering outside this change 2026-11-02 22:07:16 -06:00
Harrison Booth 0f5364ee9f picture: use displayed group size for compression 2026-11-02 22:05:35 -06:00
Harrison Booth bb417bf98a Preserve Thai distributed paragraph alignment 2026-11-02 21:53:40 -06:00
Harrison Booth d00ef08fae Preserve normalized character height in PPTX 2026-11-02 21:50:20 -06:00
Harrison Booth 0d06481c34 Preserve compatible line spacing in PPTX 2026-11-02 21:47:50 -06:00
Harrison Booth 44b133a3c9 Preserve text overflow settings in PPTX 2026-11-02 21:45:18 -06:00
Harrison Booth b66ba2e10a Preserve centered anchoring on table cells 2026-11-02 21:42:44 -06:00
Dylan M. Taylor 8ec4f6cabb Fonts: shape long items in bounded pieces (#1702)
The shaper lets one call grow to 256 glyphs per input character, as an
embedded font's substitutions decide, and every glyph was then copied out.
Items longer than 1 KiB are now shaped in pieces (cut at a space where one
is near, never before a combining mark or after a joiner), each with the
rest of the paragraph as context and the item's script, and a piece that
expands past 32 glyphs per character falls back to one glyph per character
from the cmap.
2026-11-02 21:22:47 -05:00
Dylan M. Taylor 5a668bf02e Animations: index morph matching and timeline lookups by shape (#1700)
* Animations: index morph matching and timeline lookups by shape

Morph pairing searched every shape of the next slide for each shape of the
current one, four times over, rebuilding shape text inside each comparison.
Timeline state looked up a shape's effects, paragraph owner and base colour
by scanning every effect, group and colour on the slide, once per shape and
paragraph drawn. Both are now indexed once, keeping the same match order.

* Animations: drop wall-clock bounds from the large morph and paragraph tests

On a loaded runner a ten-second bound can fail after every functional
assertion passed. The inputs stay large enough that a quadratic regression
hangs the test instead.
2026-11-02 21:18:59 -05:00
Brandon ThomasandClaude Opus 5.5 3e178d8f5a Release 0.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:27:20 -05:00
NetterSilverandClaude Haiku 5.5 58c24883c7 Reject png/jpeg targets in file.save instead of writing zip bytes
Found while testing crates/engine/src/cmd/file.rs:489 (save, with save_bytes at :341-349 and format_for_path at :440)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 01:14:15 +01:00
Dylan M. Taylor f68150ee10 Replace the Account page with About (#1697)
* UI: replace Account backstage page with About

* UI: stack About columns in narrow windows; keep PowerPoint's Account in parity doc

* UI: About review fixes: stack columns below 860pt, Help icon, Credits task; doc headers
2026-11-02 19:02:14 -05:00
Zach BerryandClaude Opus 5.5 b9eaaeb23b Test: hidden media controls are written as PowerPoint's p14 extension
The round trip now also checks the written presProps.xml: inside p:showPr, the extension uri and
p14:showMediaCtrls with its namespace declared on the element; the default writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:03:02 -06:00
Harrison Booth 3cfd4c5e6e Parse percentage-form bullet sizes 2026-11-02 15:47:58 -06:00
Harrison Booth a200b92258 Merge remote-tracking branch 'origin/main' into dc-build-d712
# Conflicts:
#	crates/model/src/text.rs
#	crates/pptx/src/read/dml.rs
#	crates/pptx/src/write/dml.rs
#	crates/pptx/tests/it/roundtrip.rs
2026-11-02 15:45:48 -06:00
Harrison Booth f2803102c1 Correct animation option labels 2026-11-02 15:41:17 -06:00
Harrison Booth fd4d2a8f79 Merge remote-tracking branch 'origin/main' into dc-build-d604
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
2026-11-02 15:36:41 -06:00
LeonardoandClaude Sonnet 5.5 e90e7f6c03 fix(render): size the tile of a picture's shadow and reflection in points too
Review of #1693 found that cast_effects built the picture content that a shadow,
a glow and a reflection are drawn from without converting a tiled fill to
points, so inside a scaled group the reflection of a tiled picture showed
child-space tiles while its face showed the right ones.

The content now goes through paint::tiled_picture_per_point with per_point_xy,
as the face does.

Test: a tiled picture's reflection in groups scaled 0.25, 1600 and 2x0.5 shows
the same colours as in an unscaled group; it fails without the change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 17:46:33 -03:00
LeonardoandClaude Sonnet 5.5 9e59b5f223 fix(render): size a group's own tile and a picture shape's own tile in points too
Review of #1693 found two more places that drew a tiled picture fill in the
shape's own (child-space) units inside a scaled group.

A group nested in a scaled group stored its own tiled fill unconverted, so the
members that take it (grpFill) got oversized tiles. The owning group now
converts the fill with per_point_xy when it stores it, so inheriting members,
directly or through a chain of grpFill groups, get the converted tile.

A picture shape (p:pic) whose blip fill is a tile passed its own fill straight
to paint::picture. It now goes through paint::tiled_picture_per_point, which
tiled_per_point also uses.

Tests: a group's tile passed to its members and a picture shape's own tile, each
in outer groups scaled 1, 0.25, 1600 and 2x0.5; both fail without the change.

Docs: the Picture and texture fill checklist row is re-assessed (remains P) with
its status line and revision history, and the ROADMAP progress log row mentions
both cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 17:31:10 -03:00
Luis 5cb6ba8403 Comments: the pane keeps its width and resizes again (fixes #1431)
A comment card's reply row reserved a fixed 70 px for its Resolve and
delete buttons, which need more. The row overflowed the card, the pane
grew by the overflow every frame until it stuck at its widest, and
dragging its edge no longer changed it. The buttons are now laid out
first, from the right, and the reply field takes the width they leave.
2026-11-02 21:03:34 +01:00
LeonardoandClaude Sonnet 5.5 62ada3ab9a fix(render): tiled picture fills keep their size in scaled groups and upright tiles
A tiled picture fill (a:blipFill with a:tile) was sized in the shape's own
units. Inside a group whose child space differs from its box (old decks write
child spaces ~1800 times smaller) one tile covered hundreds of boxes, so the
fill showed a single flat texel instead of the texture.

PowerPoint's own PNG export of a synthetic probe deck shows the tile keeps its
size, offset, anchor and mirroring in slide points whatever the group's scale,
uniform or stretched unevenly, nested or flipped. The tile is now converted
from points to shape units per axis (Renderer::per_point_xy, paint::
tiled_per_point), and snapped to whole device pixels per axis.

Tiles with rotWithShape="0" are drawn in device pixels and were sized in
pixels, ignoring the zoom (even outside groups); they are now sized to pixels
first.

Tests: a tile in groups of seven scales (uniform and uneven, wide and tall
tile), and an upright tile in a turned shape at zoom 1 and 2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 16:31:29 -03:00
NetterSilverandClaude Haiku 5.5 77cd617596 Use a short temp file name in write_file for long file names
Found while testing crates/engine/src/io.rs:49

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 13:37:19 +01:00
NetterSilverandClaude Haiku 5.5 72ac32715f Reject invalid master and layout indices in view.slideMaster
Found while testing crates/engine/src/cmd/design/master_view.rs:8 (master_view; `master` read via usize_param, `layout` at :14)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 12:21:37 +01:00
Naseq e0992c1325 Transitions: render cached previous slide with completed animation state
- Use render_frame with timeline.steps() and t = 0.0 so the previous slide
  is rendered in its final completed state during transition preview.
- Pass duration: 1000 integer ms in test to explicitly test the requested duration.
2026-11-02 15:25:31 +04:30
NetterSilverandClaude Haiku 5.5 4bed9b6c37 Reject unsupported handout perPage values in file.export
Found while testing crates/engine/src/cmd/file.rs:362 (pdf_options) with crates/pdf/src/lib.rs:364-371 (handout_boxes)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 11:50:54 +01:00
Naseq ea9dad8ef9 Transitions: play transition preview from previous slide when Preview is clicked (#1654)
- When Preview is clicked on the Transitions ribbon, initialize Show with
  start_transition_preview from the prior slide into the selected slide
  (or from black for slide 0).
- If tr.old is unset for a non-part transition with tr.from, render the
  previous slide texture on the first frame so the transition blends cleanly.
- Indicate active playback on the Preview button while previewing.
- Regression tests for transitions from previous slide and from black on slide 0.
2026-11-02 15:04:05 +04:30
Brandon Thomas 0d45a64db2 Merge pull request #1645
test: make the language-dialog test independent of installed dictionaries
2026-11-02 04:53:51 -05:00
Brandon Thomas e832a373f5 Merge pull request #1639
Text: scale down insets when wider than the shape (#1632)
2026-11-02 04:53:51 -05:00
Brandon Thomas 6c63345615 Merge pull request #1667
Fix Unit::format printing "-0.00" for tiny negative lengths
2026-11-02 04:53:50 -05:00
Brandon Thomas 1f2c75a288 Merge pull request #1631
Keep Xfrm::rot_norm within 0..360 for tiny negative rotations
2026-11-02 04:53:50 -05:00
Brandon Thomas fd4c533425 Merge pull request #1630
Apply alphaMod and alphaOff in ColorRef::alpha
2026-11-02 04:53:49 -05:00
Brandon Thomas d4d99c7627 Merge pull request #1628
Keep combining marks inside spell-check words
2026-11-02 04:53:49 -05:00
Brandon Thomas 9fce04f0b4 Merge pull request #1627
Lowercase word-final Σ as ς in title and sentence case
2026-11-02 04:53:48 -05:00
Brandon ThomasandClaude Opus 5.5 ffd77c7db0 Spell check: a mark on trailing punctuation is trimmed with it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 04:53:47 -05:00
Brandon Thomas 1f6587b60d Merge pull request #1373
PPTX: parse each tags part once and cap the tags an import copies (#1164)
2026-11-02 04:51:27 -05:00
Brandon Thomas d09f4c1284 Merge pull request #1247
H.264: temporal direct mvs are scaled in i32 and clipped to the spec range
2026-11-02 04:51:26 -05:00
Brandon Thomas 37a6aed679 Merge pull request #1614
Fonts: unavailable monospace fonts fall back to a monospace substitute (#1598)
2026-11-02 04:51:26 -05:00
Brandon Thomas 02ea528e94 Merge pull request #1626
render: keep even-odd holes open in shadow, glow and reflection silhouettes
2026-11-02 04:51:25 -05:00
Brandon Thomas bcc4fc97e6 Merge pull request #1558
Allow resizing on all selected shapes (#1539)
2026-11-02 04:51:19 -05:00
Brandon Thomas f4fcac9f2d Merge pull request #1623
Let clicks pass through empty No Fill shapes
2026-11-02 04:51:18 -05:00
Brandon Thomas babc950f81 Merge pull request #1601
Reject invalid slide index in design.background
2026-11-02 04:51:17 -05:00
Brandon Thomas 468394b3c8 Merge pull request #1600
Shape Fill: the gradient help lists every kind and key the command takes
2026-11-02 04:51:17 -05:00
Brandon ThomasandClaude Opus 5.5 5eb0b7d2c0 docs: one revision row per doc for #1621, keep the Last reviewed dates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 04:51:13 -05:00
Brandon ThomasandClaude Opus 5.5 01220df579 design.background: check the index before a picture is added to the media
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 04:51:01 -05:00
NetterSilverandClaude Haiku 5.5 b0b87c5cad Fix Unit::format printing "-0.00" for tiny negative lengths
Found while testing crates/geom/src/lib.rs:72-76

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 05:37:53 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 1192e5ef30 Detached placeholders follow each property's style precedence
Review follow-up (CodeRabbit on #1658):

- Text levels are resolved before the copy's style changes. A master
  placeholder's font reference reached the copy's text (body font in an
  accent colour for a title) although the original ignores it: text only
  takes the shape's or the layout placeholder's font reference.
- Fill, line and effects come from where `resolve::fill`, `resolve::line`
  and `resolve::effects` take them: the first shape in the chain with an
  explicit value or a style. A layout's (or the slide's own) style no
  longer loses to an explicit fill, line or effect on the master. The
  copy gets one style whose references each come from the shape that
  supplied that property; unused references are neutral.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:06:09 +01:00
Simon Koch-SultanandClaude Opus 5.5 befa4cd69f Merge main into test/portable-render-validation
Main fixed the PDF font fixture independently (9005839), so that half
of this branch is dropped in favour of main's version; the language
dialog label fix remains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:54:25 -05:00
Stephan Schott-VerdugoandClaude Opus 5.5 f006b5d8ce Ctrl+V pastes a picture copied in a browser, not its link
A browser's Copy Image puts the picture on the clipboard together with
text that only names it (the address and alt text, or Firefox's UTF-16
text/x-moz-url read as UTF-8) and an <img> HTML fragment. Ctrl+V only
looked for a picture when the text was empty, so it pasted a text box
with the link. When the text or HTML only names a picture, paste the
picture; with no picture on the clipboard the link still pastes as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:38:05 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 e1cfe8516e Paste and duplicate keep a placeholder's inherited look
A placeholder copied or duplicated on a slide became a regular shape by
clearing `ph`, which also dropped everything it inherited from its layout
and master: the title's 44 pt heading font, the body's bullets and indent
levels, autofit and the box. Without `ph` the text fell back to the
presentation's default style (18 pt, no bullets), on Ctrl+V and the
context menu alike.

`resolve::detach_placeholder` makes the inherited box, fill, line,
effects, body properties and every level's text style the shape's own
before `ph` is cleared. Theme references stay references, so the default
paste still follows the destination theme. Copy (so Cut and pasting into
another deck work after the source is gone) and Duplicate use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:29:43 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 cbb0c48445 Fix OS clipboard: enable eframe's clipboard feature dropped in the egui 0.37 migration
Since #1350 (v0.11.1) the desktop build listed eframe features without
`clipboard`, so egui-winit simulated a clipboard inside the app: Ctrl+V
never read text or pictures from other apps, and Ctrl+C of text (sent out
as HTML through arboard) never reached egui's clipboard, so Ctrl+V pasted
nothing or stale text. The context menu kept working because it only uses
the session clipboard.

Turn the feature back on. On native Wayland, egui-winit owns the clipboard
through winit's data device (text and PNG); leave the X11-only arboard
pasteboard (HTML, pictures) off there so copies are claimed on the Wayland
selection. Regression tests guard the feature list and the Wayland switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:19:06 +01:00
Simon Koch-SultanandClaude Opus 5.5 cc00a17dfe #1596: let effect-hole QA images overwrite an earlier run's
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:52:54 -05:00
FredAppsandFredApps 90058395b7 WASM: open presenter view in a separate browser window (#729) (#730)
* docs: track WASM presenter popout work (#729)

* Fix presenter transport retries and initialize popup note fonts

* Clarify optional font coverage and source-verified popup lifecycle

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-01 21:36:45 +01:00
Brandon ThomasandClaude Opus 5.5 ee39ff6781 Release 0.14.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:14:22 -05:00
Brandon ThomasandClaude Opus 5.5 bbd579813f packaging(flatpak): flatpak-oci.sh sources common.sh, not the absent env.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:14:20 -05:00
Brandon ThomasandClaude Opus 5.5 eaf8d1ce3f Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:57:23 -05:00
Dylan M. Taylor f2f0c4373d docs: sync Craft App Suite list with ArtCraft Launcher 2026-11-01 12:30:03 -05:00
SusieeTheFemboyandClaude Opus 5.5 14cc73101b H.264: cite temporal direct as 8.4.1.2.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:46:05 +01:00
scks1 650c4f9497 #1597: make validation independent of installed fonts and dictionaries 2026-11-01 08:58:13 -05:00
scks1 9f952550b2 #1596: add shadow, glow and reflection pixel goldens 2026-11-01 08:58:11 -05:00
xmaks82andМагда М.Г. 5fb694d991 Text: a word doesn't break after ?, !, } or + (URLs wrap as in PowerPoint) (#924)
* Text: a word doesn't break after ?, !, } or + (URLs wrap as in PowerPoint)

Unicode line breaking allows a break after ?, !, } and + inside a word,
so a long URL broke after its '?' and after each '+' of a query and left
the rest of the line empty. Measured in PowerPoint 2016 for Windows with
latinLnBrk off (its default), none of them is a break: the word fills the
line and breaks where it no longer fits, as PowerPoint already does after
/ and \ (break_opportunities). The same synthetic slide now wraps line for
line as PowerPoint's PDF does, a Yandex search URL included.

* Text tests: the first line doesn't end at the separator

* Text tests: check the line is full, not that it avoids ending at a separator

* Keep the Unicode break after ?, !, / outside Latin words

The PowerPoint no-break rule only holds inside a word: before an ideograph
(日本!語) or punctuation the Unicode break stays, as CodeRabbit pointed out.

* Only letters and digits continue a word for the no-break rule

AL and AI also hold symbols such as & and ×, so a!&b kept no break after !.
is_word_char now also requires c.is_alphanumeric() (CodeRabbit).

* Test that Latin before CJK and a separator before a space still break

---------

Co-authored-by: Магда М.Г. <mail@mgmagda.ru>
2026-11-01 10:54:32 +01:00
Elliot Graham 7f742c9d4f FIX-1621: cover locked and outline-free No Fill shapes 2026-11-01 09:29:42 +00:00
Naseq b5033b6213 Text: preserve signed insets and scale only when both are non-negative
Address review finding: keep signed insets unchanged when their sum fits,
scaling opposing insets proportionally only when both in the pair are
non-negative and their sum exceeds the box dimension.
2026-11-01 13:38:14 +04:30
Naseq b6c1d87cf0 Text: scale down insets when wider than the shape (#1632)
When text insets exceed the shape dimensions (e.g. lIns + rIns > width),
proportionally scale them down so the inner text rectangle remains
bounded within the shape. Prevents off-slide helper shapes with large
insets from having their text center shifted onto the slide canvas.
2026-11-01 11:54:38 +04:30
Naseq c37b150950 PPTX: parse each tags part once, cap copied tags, and bound cache (#1164)
- Parse each tags part once per import and cache in `Imp::tags_by_part`
- Cap total copied tags into the model at `MAX_TAG_BYTES` (64 MiB), charging each pair a fixed size_of::<(String, String)>() in addition to text
- Bound the cache: once `tags_capped` is set, return early before parsing or caching any subsequent part
- Tests covering deduplicated part copies, empty-tag pair budgets, and verifying unread tag parts are not parsed or cached after the budget is reached
2026-11-01 11:30:12 +04:30
MarekSpodBiedry 74d7b844fe Merge origin/main into issue-1153 2026-11-01 07:41:05 +01:00
MadhasoekiandCopilot f3d8ba85a6 tests(fonts): add specific tests requested by reviewer for monospace resolution
Verify Consolas, Courier New, Fira Code, Monotype Corsiva, Monoton,
and ensure proportional family substitutes remain unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-01 11:35:53 +07:00
Dylan M. Taylor e690829543 ci: grant packages write permission to release workflow 2026-10-31 23:43:08 -04:00
MadhasoekiandCopilot c0468b59cd Fonts: classify Cascadia Code PL as monospace (#1598)
Add Cascadia Code PL and Cascadia Mono PL to the monospace family
matching list, and add regression tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-01 10:31:17 +07:00
NetterSilverandClaude Haiku 5.5 5d27461fa6 Keep Xfrm::rot_norm within 0..360 for tiny negative rotations
Found while testing crates/geom/src/lib.rs:155-158

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 02:55:58 +01:00
NetterSilverandClaude Haiku 5.5 773133ec42 Apply alphaMod and alphaOff in ColorRef::alpha
Found while testing crates/model/src/style.rs:59-63

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 00:52:04 +00:00
MadhasoekiandCopilot 8ec4f29059 Fonts: refine monospace heuristic and preserve office_twin order (#1598)
- Avoid false-positive monospace match on 'code' in split_whitespace for
  proportional families like Code Pro.
- Check office_twin before is_monospace_family in document_face so alias
  families like Courier New CE resolve to Courier New when installed.
- Add negative tests for Code Pro and test Courier New CE mapping.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-01 07:19:41 +07:00
NetterSilverandClaude Haiku 5.5 87a10fc8e2 Keep combining marks inside spell-check words
Found while testing crates/engine/src/spell.rs:693 (paragraph_words, the `inside` closure)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 00:19:15 +01:00
NetterSilverandClaude Haiku 5.5 3fb6c43136 Lowercase word-final Σ as ς in title and sentence case
Found while testing crates/model/src/edit.rs:470-497 (change_case_parts, title/sentence branch)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 00:08:51 +01:00
Dylan M. Taylor 82573331a8 packaging(flatpak): add OCI image build and GHCR publishing helper (#1625) 2026-10-31 18:52:58 -04:00
Simon Koch-Sultan d990678b32 render: keep even-odd holes open in shadow, glow and reflection silhouettes
effects_below merged all subpaths into one silhouette and filled it
with the NonZero rule, discarding each subpath's even_odd flag, so the
holes of donut, frame and similar preset shapes filled in under outer
shadows, glows and reflections. Fill each participating subpath under
its own fill rule at the three effect fill sites, mirroring
fill_geometry; the glow stroke keeps the merged silhouette.

Fixes #1596. The picture-content shadow clip path is unchanged
(that is #1597's territory).
2026-10-31 17:18:44 -04:00
Elliot Graham cd19752bb5 FIX-1621: let no-fill interiors pass clicks to underlying objects 2026-10-31 19:38:08 +00:00
MadhasoekiandCopilot 2ed2ce18d8 Fonts: unavailable monospace fonts fall back to a monospace substitute (#1598)
When a document uses a monospace font that is not installed on the
system (such as Cousine, Source Code Pro, JetBrains Mono, Consolas, or
Cascadia Code), document_face previously fell back to the generic
proportional MISSING_FONT_FAMILY (Calibri), causing code snippets and
columns to misalign.

Add is_monospace_family to recognize monospace families, ensure
substitutes returns open monospace replacements (Liberation Mono,
Courier New, Menlo, Courier, DejaVu Sans Mono), recognize Cousine as
a metric twin of Courier New, and keep monospace families on their
substitute chain in document_face.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-31 22:25:50 +07:00
DominicTheD3v c5ac1dd44b docs: add missing docstrings 2026-10-31 07:52:55 +00:00
DominicTheD3v 84ac51a439 fix: addressing more issues 2026-10-31 07:04:09 +00:00
NetterSilverandClaude Haiku 5.5 c3e9bd07e5 Reject invalid slide index in design.background
Found while testing crates/engine/src/cmd/design/slide_background.rs:65 (index via usize_param); write_background at :175

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 06:38:17 +01:00
Dylan M. Taylor a3fbf02952 Merge pull request #1592 from aiongg/docs-tidy
docs: tidy agent, contributor and roadmap docs
2026-10-31 01:06:52 -04:00
Dylan Taylor 92bf7fc67d fix(xtask): update SHARED_REV for craft-libs update 2026-10-31 01:05:27 -04:00
Dylan M. Taylor 0ed4b394ac Merge pull request #1599 from aiongg/update-result-dialog
Updates: shared check-result dialog from craft-update
2026-10-31 01:05:19 -04:00
dotCooCoo e747bff425 Shape Fill: the gradient help lists every kind and key the command takes
The shape.fill params doc listed only linear and radial gradients with
stops, angle and kind. The command also takes the rectangular and path
kinds, focus and rotateWithShape. The doc now names them, and a test
checks the doc's gradient keys and kinds against what the command reads.
2026-10-30 22:02:08 -07:00
Brandon Thomas f7a7fd7f5f Merge pull request #1584
Animations: animation.add and animation.set take the points of a custom motion path
2026-10-31 00:59:38 -04:00
Brandon ThomasandClaude Opus 5.5 800ffeaa59 Motion paths: check raw path strings without deckcraft-anim, which the engine only has as a dev-dependency
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:59:38 -04:00
Brandon Thomas d080a23dc0 Merge pull request #1584
Animations: animation.add and animation.set take the points of a custom motion path
2026-10-31 00:56:36 -04:00
Brandon Thomas 243c1a269f Merge pull request #1582
Ogg: bound the work and the warnings of damage recovery
2026-10-31 00:56:35 -04:00
Brandon Thomas 06a1ca4672 Merge pull request #1521
Slideshow click targets follow animated shapes
2026-10-31 00:56:35 -04:00
Brandon Thomas 40728b5362 Merge pull request #1591
Preserve gradient flip mode in PPTX

# Conflicts:
#	crates/pptx/src/read/dml.rs
#	crates/pptx/src/write/dml.rs
#	crates/pptx/tests/it/roundtrip.rs
2026-10-31 00:56:34 -04:00
Brandon ThomasandClaude Opus 5.5 d2504e5f8b Motion paths: a raw path string must parse to at least two finite points
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:56:17 -04:00
Brandon Thomas d92874f541 Merge pull request #1574
Add read-only slide action inspection
2026-10-31 00:56:17 -04:00
Brandon ThomasandClaude Opus 5.5 6a34f13808 Ogg: the budget-spent warning goes last, after the capped warnings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:54:52 -04:00
Brandon ThomasandClaude Opus 5.5 6058fc7882 gaps.md: drop the stray 10-28 click-target row from among the 10-13 rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:54:38 -04:00
Brandon ThomasandClaude Opus 5.5 04b0958bee Gradient flip: drop the blank lines before the new flip fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:54:16 -04:00
Brandon ThomasandClaude Opus 5.5 acf054852c slide.actions refuses a bad index instead of using the selected slide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:53:14 -04:00
Brandon Thomas 861879f8ee Merge pull request #1519
Use private directories for print previews
2026-10-31 00:51:49 -04:00
Brandon Thomas e626625d40 Merge pull request #1514
Reject picture slices in codec configuration records
2026-10-31 00:51:48 -04:00
Brandon Thomas 4a0f48a04c Merge pull request #1590
Preserve inset line alignment in PPTX

# Conflicts:
#	crates/model/src/style.rs
#	crates/pptx/tests/it/roundtrip.rs
2026-10-31 00:51:48 -04:00
Brandon Thomas 8f6e1b420a Merge pull request #1589
Preserve bullet Follow Text settings

# Conflicts:
#	crates/pptx/tests/it/roundtrip.rs
2026-10-31 00:51:37 -04:00
Brandon Thomas c0db6ebeba Merge pull request #1585
Preserve explicit line miter limits
2026-10-31 00:51:37 -04:00
Brandon Thomas a667e13a03 Merge pull request #1594
Parse uppercase exponent in custom geometry path numbers
2026-10-31 00:51:36 -04:00
Brandon ThomasandClaude Opus 5.5 3bf35c801f Custom geometry: 'E' is an exponent only when digits follow, so 'M 0 0E' keeps its end command
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:51:36 -04:00
Brandon Thomas 47eb6732fa Merge pull request #1581
contributors: add names for marshiyar
2026-10-31 00:49:38 -04:00
Brandon Thomas 8a2eccc863 Merge pull request #1583
MCP: insert_chart takes x, y, w and h
2026-10-31 00:49:38 -04:00
Brandon Thomas fb05eaa30f Merge pull request #1490
Make layout/master placeholder formatting reach slides

# Conflicts:
#	crates/engine/src/tests.rs
2026-10-31 00:49:37 -04:00
Brandon Thomas 231a52438c Merge pull request #1587
Reject invalid colour in format.highlight instead of clearing it
2026-10-31 00:49:36 -04:00
Brandon Thomas a00b7e8a51 Merge pull request #1577
Reject invalid index in transition commands instead of using selection
2026-10-31 00:49:36 -04:00
aiongg 9678ed7d7f updates: show check results with craft-update's CheckResultDialog 2026-10-31 04:42:16 +00:00
NetterSilverandClaude Haiku 5.5 1de7b44a8d Parse uppercase exponent in custom geometry path numbers
Found while testing crates/pptx/src/custgeom.rs:334

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 05:33:56 +01:00
Harrison Booth 5a235590dd Move gradient editor test module to file end 2026-10-30 23:26:55 -05:00
aiongg a96dcdbe8f docs: tidy wording 2026-10-31 04:25:45 +00:00
Harrison Booth 98138f563b Trim whitespace in bullet point sizes 2026-10-30 23:24:07 -05:00
Harrison Booth 0e3f4cd353 Preserve explicit bullets over follow-text resets 2026-10-30 23:20:43 -05:00
Harrison Booth 72c8621358 Keep gradient flip when editing a fill 2026-10-30 23:17:32 -05:00
aiongg cccc9064ca docs: tidy agent, contributor and roadmap docs 2026-10-31 04:17:24 +00:00
Harrison Booth 1de0101ced Honor miter percentage schema bounds 2026-10-30 23:11:12 -05:00
Harrison Booth 917d4b96a9 Honor bullet size schema bounds and inheritance 2026-10-30 23:07:54 -05:00
Harrison Booth f5c2aca644 Preserve bullet follow-text inheritance 2026-10-30 23:05:16 -05:00
Harrison Booth 4408ff6153 Restrict line alignment to schema values 2026-10-30 23:00:20 -05:00
Harrison Booth ad97533aec Preserve gradient flip mode in PPTX 2026-10-30 22:57:14 -05:00
Harrison Booth 727b682d17 Preserve inset line alignment in PPTX 2026-10-30 22:48:31 -05:00
Harrison Booth 8770fb90c3 Preserve bullet follow-text resets in PPTX 2026-10-30 22:45:55 -05:00
Harrison Booth b1900e526d Preserve point-sized bullets in PPTX 2026-10-30 22:42:28 -05:00
NetterSilverandClaude Haiku 5.5 9a716be59e Reject invalid colour in format.highlight instead of clearing it
Found while testing crates/engine/src/cmd/format/font.rs:228-231 (highlight), color_param at crates/engine/src/cmd.rs:381

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 04:41:09 +01:00
Harrison Booth 479c3cc5ea Preserve explicit miter limits in PPTX 2026-10-30 22:24:08 -05:00
marshiyar b7482dd392 engine: animation.add and animation.set take the points of a custom motion path
What was wrong: animation.add ignored a `path` parameter, so a customPath
animation always got the default straight path of a quarter of the slide
height, and no command could set or change the points of one.

What changed: animation.add, animation.set and animation.options accept `path`
for a motion path: either points [[x, y], ...] in slide points, as offsets from
where the shape sits (converted to the stored slide-fraction path; `closed`
returns to the first point), or the OOXML path string animation.get answers.
At most 1000 points, finite numbers within 1e6 points, and strings of at most
16 KiB made of path commands and numbers are accepted; anything else is
refused before the deck changes, as is `path` on an animation that is not a
motion path. animation.add answers the stored path.

How it was tested: three new tests in cmd/animation.rs: points become the
expected path (add, set with `closed`, back to the default without `path`), a
raw string is kept and animation.options replaces it, and malformed paths are
refused with nothing added. The animation tests pass, and clippy is clean with
-D warnings.

Fixes #1576
2026-10-30 20:13:05 -07:00
marshiyar e83f964077 mcp: insert_chart takes x, y, w and h
What was wrong: the insert_chart tool had no x, y, w or h in its schema, so the
server refused them as unknown arguments, while insert_picture and insert_table
take them. A chart could only be placed at the default box, [144, 81, 672, 378]
on a 960x540 slide, and then had to be moved with a second command.

What changed: insert_chart declares x, y, w and h and folds them into the
engine's `rect` when all four are given, as insert_picture and insert_table do.
Without all four the chart keeps its default place.

How it was tested: insert_chart_takes_a_box failed before the change (the
arguments were refused) and passes after it. It checks the box of the placed
chart, that a partial box leaves the default place, and that an extreme box is
answered without a panic. The rest of the deckcraft-mcp tests pass, and clippy
is clean with -D warnings.

Fixes #1151
2026-10-30 20:13:00 -07:00
marshiyar ede7a7ead7 ogg: bound damage recovery work and the warnings list
What was wrong: when a page failed its CRC the demuxer had already read the
whole page (up to about 64 KiB), then moved on by one byte and tried again.
Nothing was charged for it, and only the scan for the next capture pattern had
a budget, which a candidate that matches "OggS" at the current position never
reaches. A file made of capture patterns 64 bytes apart, each claiming a full
page, made a 1 MB file read about 957 MB and compute a CRC over all of it. The
warnings list also grew with every damaged page, without limit.

What changed: the per-file recovery budget (32 MiB, the scan's existing one) is
now also charged with the length of every page read only to find its CRC wrong.
When it is spent the pages found so far are returned with a warning saying the
rest was not searched. At most 256 warnings are kept, plus one saying the rest
were dropped.

How it was tested: two new tests failed before the fix and pass after it, a
counting ByteSource over 1 MB of dense fake pages (bytes read stay within the
budget; before, 957 MB were read) and 100,000 bad-CRC pages (before, 199,999
warnings). A file of good pages is not charged and has no warnings. The other
ogg tests still pass, and clippy is clean with -D warnings.

Completes the Ogg part of storytold/filmcraft#2794
2026-10-30 18:27:08 -07:00
DominicTheD3v 68dda6f7d8 Merge branch 'feature/resize-multiple-shapes' of https://github.com/dominicthed3v/deckcraft into feature/resize-multiple-shapes 2026-10-31 01:04:36 +00:00
DominicTheD3v 9aef8cc5ab fix: addressing issues raised by coderabbit 2026-10-31 01:03:53 +00:00
marshiyar 6113f4e1a2 contributors: add names for marshiyar 2026-10-30 17:57:10 -07:00
DominicTheD3vandcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> 77e3a444b8 Update crates/engine/src/tools/drag.rs
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-31 00:21:06 +00:00
Harrison Booth d453bee54f D604: readable animation effect labels 2026-10-30 18:57:51 -05:00
DominicTheD3v 09bbfc7217 fix: addressing issues raised by coderabbit 2026-10-30 23:25:01 +00:00
NetterSilverandClaude Haiku 5.5 443f503407 Reject invalid index in transition commands instead of using selection
Found while testing crates/engine/src/cmd/transition.rs:47-54 (slide_indices, via usize_param)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 23:16:32 +00:00
DominicTheD3v e812da58df fix: addressing issues 2026-10-30 22:51:33 +00:00
Harrison Booth ee4bdbcd7d fix: initialize slide action test run props 2026-10-30 17:23:00 -05:00
NetterSilverandClaude Sonnet 5.5 adaa4e6fa1 fix: keep per-level formatting when formatting layout/master placeholders
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 23:22:33 +01:00
NetterSilverandClaude Opus 5.5 446bc528ad Make layout/master placeholder formatting reach slides
Fixes #1487

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:17:36 +01:00
Harrison Booth 3c068df628 test(h264): complete picture slice fixture 2026-10-30 17:13:38 -05:00
Harrison Booth f8165751e5 Merge origin/main into dc-slide-actions-list 2026-10-30 17:12:14 -05:00
Harrison Booth 7190c85860 test(h264): avoid singleton fixture loop 2026-10-30 17:06:36 -05:00
Harrison Booth ddd39249ba test(codec): strengthen configuration slice regressions 2026-10-30 17:03:36 -05:00
Harrison Booth b4c1905d78 dc-fix-1519: address print preview review 2026-10-30 16:51:34 -05:00
Harrison Booth 2c11865b2d codec: accept hvcC SEI arrays without allocation 2026-10-30 16:49:21 -05:00
Harrison Booth b92a7ddbd5 Merge remote-tracking branch 'origin/main' into dc-fix-1514 2026-10-30 16:37:40 -05:00
Harrison Booth 0768e95591 Print preview: merge origin/main 2026-10-30 16:36:30 -05:00
DominicTheD3v 82adf7326e fix: address raised issues 2026-10-30 21:13:44 +00:00
Harrison Booth ef6fd29928 DC-1521: hit-test each shape animation independently 2026-10-30 16:00:43 -05:00
Harrison Booth a3449185ac DC-1521: merge origin/main 2026-10-30 15:59:33 -05:00
Zach BerryandClaude Sonnet 5.5 6303166a18 [PP1] show.setup showMediaControls survives a pptx save and reopen
PowerPoint stores Show media controls as the p14:showMediaCtrls extension inside
p:showPr (uri {2FDB2607-...}); absent means shown. Write it when hidden, read it back.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 15:12:26 -05:00
Harrison Booth 90c8f65f5e Merge remote-tracking branch 'origin/main' into dc-slide-actions-list 2026-10-30 14:01:06 -05:00
Harrison Booth b082f12f70 #129: inspect slide actions 2026-10-30 14:01:04 -05:00
DominicTheD3v 32ef926c46 fix: address CodeRabbit review findings 2026-10-30 01:46:06 +00:00
DominicTheD3v 4c1916466b Allow resizing on all selected shapes 2026-10-30 01:19:44 +00:00
Harrison Booth 14eef3aceb dc-show-click-targets: follow animated show click targets 2026-10-28 16:09:41 -05:00
Harrison Booth 33b6e11726 Use private directories for print previews 2026-10-28 15:48:37 -05:00
Harrison Booth 8c2560f31c codec: validate configuration NAL types 2026-10-28 15:06:23 -05:00
NetterSilverandClaude Haiku 5.5 d8936f94af Keep whole character after a backslash in RTF outline import
Found while testing crates/format/src/rtf.rs:301-326 (symbol / text, reached via escape)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:58:38 +01:00
SusieeTheFemboyandClaude Opus 5.5 48c5ff67bf H.264: the temporal-direct test runs the dsf 512 case
The dsf 512 / mvCol [32767, 0] check sat inside loops that never
produce that pair, so it never ran. It is now a direct assertion after
the sweep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:40:21 +02:00
SusieeTheFemboyandGLM-5.3 78ab8f0626 H.264: temporal direct mvs are scaled in i32 and clipped to the spec range
The temporal direct derivation truncated (mvCol · DistScaleFactor + 128) >> 8
with `as i16`, wrapping mvL0 past the spec's motion-vector range on crafted
streams, and mvL0 − mvCol then panicked a debug build with "attempt to
subtract with overflow". The scale and the subtraction now run in i32 (dsf is
already clamped to ±1024, so they cannot overflow) and only the results are
clipped to −2^15..2^15−1, as ITU-T H.264 8.4.1.2.4 reads — mvL1 scaled from
the unclamped mvL0, the shape review settled for the same decoder code in
SoundCraft (soundcraft#994): dsf 512, mvCol 32767 keeps mvL1 at 32767.

Extends the grouped robustness audit's codec-overflow list (#1183), which
covers the POC/MMCO/latency sites but not this one.

Co-Authored-By: GLM-5.3 <noreply@z.ai>
2026-10-24 00:34:09 +02:00
MarekSpodBiedry 813378cc20 Keep the PDF text layer aligned with line breaks, use one clock reading for raster and text layer, and test the page text (#1153) 2026-10-23 16:08:53 +02:00
MarekSpodBiedry cc6773c95f Use evaluated field values in the placed text so the PDF text layer shows the slide number (#1153) 2026-10-22 21:02:59 +02:00
335 changed files with 13717 additions and 53810 deletions
+31
View File
@@ -0,0 +1,31 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/ci.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/freebsd.yml': []
'.github/workflows/house-rules.yml': []
'.github/workflows/lockfile.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/nix.yml': []
'.github/workflows/packaging-lint.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/release.yml': []
'.github/workflows/sonarcloud.yml': []
'.github/workflows/windows-arm64.yml': []
dependencies:
'actions/checkout@v7.0.1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'step-security/harden-runner@v2.22.4':
ref: 'v2.22.4'
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
owner_id: 88700172
repo_id: 422287306
+5 -1
View File
@@ -26,8 +26,12 @@ jobs:
# Same craft-fonts commit as release.yml.
CRAFT_FONTS_REF: 37827adc41fd165f9a9234ac40f94dbad353aa69
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+34
View File
@@ -0,0 +1,34 @@
name: Lockfile
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
# Docs: https://gh.io/actions-lockfile
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
env:
GH_TOKEN: ${{ github.token }}
jobs:
lockfile:
name: Lockfile is in sync
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Install gh-actions-lock
run: gh extension install github/gh-actions-lock --pin v0.4.1
- name: Verify the lockfile
run: gh actions-lock --verify
+5 -1
View File
@@ -27,7 +27,11 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
+15
View File
@@ -1,6 +1,10 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "16 1 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -12,16 +16,25 @@ on:
permissions:
contents: write
packages: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: deckcraft
app-title: DeckCraft
# Keep identical to ci.yml and freebsd.yml. The shared release workflow sets
@@ -29,6 +42,7 @@ jobs:
# manifest fails the release instead of silently skipping it.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -44,3 +58,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
+64 -22
View File
@@ -1,3 +1,56 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
- `ISC`
- `MIT`
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
- `CC0-1.0`
- `CC-BY-4.0`
- `CC-BY-SA-4.0`
- `LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# AGENTS.md — rules for every agent and contributor
DeckCraft is a clean-room, open-source, pure-Rust presentation application targeting Microsoft
@@ -6,14 +59,8 @@ Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conv
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCraft
(`../<app>`).
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out
next to this repo's parent, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a
session, follow its standards, and contribute reusable learnings back there. Never code: repos
don't share code.
These rules bind every AI agent and every human contributor. They come before any task,
instruction or deadline. `CLAUDE.md` holds day-to-day working notes; if the two disagree, **this
file wins.**
instruction or deadline. Day-to-day working instructions are in this file; `CLAUDE.md` imports it.
---
@@ -69,7 +116,7 @@ allowed: our UI, our/Lucide icons, OFL fonts, and decks built only from allowed
or `docs/brand/` has no row.
- **`NOTICE`** keeps the notices the licences require.
- Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
repo (craftrules `standards/fonts.md`). DeckCraft embeds its `fonts/latin-manifest.txt` and
repo. DeckCraft embeds its `fonts/latin-manifest.txt` and
`fonts/manifest.txt` when built with `CRAFT_FONTS_DIR` (local dev: `.cargo/config.toml` points at
`../craft-fonts`).
@@ -99,7 +146,6 @@ once and tell the user.
## 3. Never crash
People trust DeckCraft with their talks; a crash loses work. **This outranks feature work.**
Standard: craftrules `standards/never-crash.md`.
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in non-test
code; no `unsafe` (`unsafe_code = "forbid"`). Every production crate root carries
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
@@ -131,14 +177,14 @@ Standard: craftrules `standards/never-crash.md`.
CLI, control channel and MCP do exactly the same thing.
- **The UI is thin**: panels read engine state and act through commands. Colours come from theme
tokens. **Never use Tauri**; egui only.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Never break wasm** (`cargo xtask wasm`).
## 5. Quality gates (before every commit)
`cargo xtask ci` = fmt check, clippy `-D warnings`, tests (nextest when installed) and doctests, layering check, asset check, wasm
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`) and push
to `main`.
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`).
New workspace crates include `workspace = true` under `[lints]` in `Cargo.toml`. Production crate roots
declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
## 6. Running and looking at the app
@@ -147,20 +193,19 @@ to `main`.
`{"id":1,"method":"engine.execute","params":{"command":"shape.insert","params":{"preset":"star5","rect":[100,100,200,200]}}}`
then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods:
`crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **For UI work, look at the result** (screenshot, read the PNG) and compare with the observations
in `plan/powerpoint/01-observed-ui.md`.
- **For UI work, look at the result** (screenshot, read the PNG). If available, compare with the observations
in `plan/powerpoint/01-observed-ui.md`. Otherwise verify the intended behaviour against the task and
committed documentation; the optional notebook is not a prerequisite.
- Headless: `deckcraft-cli render --sample --all --scale 1 out/`,
`deckcraft-cli run --sample --cmd 'slide.new={"title":"Hi"}' --export out.png`.
- MCP: `deckcraft-cli mcp` (headless) or `deckcraft-cli mcp --connect 7990` (drive the app).
See `docs/mcp.md`.
- Shell gotcha on the dev machine: `mv`/`cp` are aliased interactive; use `/bin/mv -f`, `/bin/cp -f`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own.
## 7. Planning
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
docs follow craftrules `standards/progress-docs.md`:
`plan/` is an optional gitignored notebook (status, execution notes, `powerpoint/` observations). Do not ask for it and do not stop when it is absent. The committed progress
docs:
| File | Holds |
|---|---|
@@ -184,14 +229,11 @@ the `ROADMAP.md` progress log, and update each touched doc's status line and rev
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
+1 -1
View File
@@ -18,7 +18,7 @@ artwork. The ribbon and menu icons stay original drawings.
| `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README header (dark mode) |
| `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README footer, About |
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds |
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks |
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | ArtCraft brand kit | — (licence text) | Terms for the ArtCraft marks |
| `assets/app-icon/deckcraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.deckcraft.png` | DeckCraft contributors | rendered by DeckCraft from `assets/app-icon/icon-source.deckcraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn |
| `packaging/macos/dmg/background.svg` | @XusBadia | DeckCraft macOS DMG window background, original work for this repository (2026-10-08), derived from the DeckCraft app icon `assets/app-icon/deckcraft-1024.png` (referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Finder window of the macOS DMG; `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
+1 -26
View File
@@ -1,26 +1 @@
# DeckCraft — working notes for Claude
Read [AGENTS.md](AGENTS.md) first: its rules (asset policy, clean room, never crash) bind you and
win over anything here.
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
before modules or other crate items; crate-level `//!` documentation may precede them.
## Start every session
1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`.
2. Read `../../craftrules/AGENTS.md` and the standards it lists for your task.
3. Run `cargo xtask ci` before you change anything, so you know the tree is green.
## Habits
- Every user-visible action is a command with tests (`crates/engine/src/cmd/*`).
- Look at UI changes: `deckcraft --sample --control 7990` + `ui.screenshot`, or
`deckcraft-cli render`.
- Commit after each landed arc (`M<n>.<k>: what`) and push to `origin main`.
- Keep `ROADMAP.md`, the progress docs in `docs/` (AGENTS.md §7) and `plan/STATUS.md` current.
- Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
@AGENTS.md
Generated
+203 -111
View File
@@ -565,18 +565,42 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "calloop"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix 1.1.5",
"tracing",
]
[[package]]
name = "calloop-wayland-source"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
dependencies = [
"calloop",
"calloop 0.13.0",
"rustix 0.38.44",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "calloop-wayland-source"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
"calloop 0.14.5",
"rustix 1.1.5",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "cast"
version = "0.3.0"
@@ -820,15 +844,24 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-audio"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=09dcbc225b95232998498a40ca56e24716571d53#09dcbc225b95232998498a40ca56e24716571d53"
dependencies = [
"craft-media",
"thiserror 2.0.21",
]
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -851,7 +884,7 @@ dependencies = [
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=09dcbc225b95232998498a40ca56e24716571d53#09dcbc225b95232998498a40ca56e24716571d53"
dependencies = [
"thiserror 2.0.21",
]
@@ -859,7 +892,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -868,7 +901,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -888,7 +921,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -898,6 +931,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -911,6 +945,36 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-video"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=09dcbc225b95232998498a40ca56e24716571d53#09dcbc225b95232998498a40ca56e24716571d53"
dependencies = [
"craft-media",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -984,7 +1048,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"arboard",
"chrono",
@@ -1006,32 +1070,16 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
"serde",
]
[[package]]
name = "deckcraft-av1"
version = "0.13.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "deckcraft-bitstream"
version = "0.13.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-cli"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -1041,14 +1089,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"chrono",
"deckcraft-anim",
@@ -1074,7 +1122,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -1086,7 +1134,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"deckcraft-model",
"serde",
@@ -1097,47 +1145,15 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"kurbo",
"serde",
]
[[package]]
name = "deckcraft-h264"
version = "0.13.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "deckcraft-hevc"
version = "0.13.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "deckcraft-isobmff"
version = "0.13.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-matroska"
version = "0.13.1"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-mcp"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1147,16 +1163,11 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
"deckcraft-hevc",
"deckcraft-isobmff",
"deckcraft-matroska",
"deckcraft-ogg",
"deckcraft-opus",
"deckcraft-vp9",
"craft-audio",
"craft-media",
"craft-video",
"image",
"log",
"symphonia",
@@ -1165,7 +1176,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"image",
"kurbo",
@@ -1174,7 +1185,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1184,17 +1195,9 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "deckcraft-ogg"
version = "0.13.1"
[[package]]
name = "deckcraft-opus"
version = "0.13.1"
[[package]]
name = "deckcraft-pdf"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1212,7 +1215,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1228,7 +1231,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1247,7 +1250,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1261,7 +1264,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"craft-ui",
"craft-update",
@@ -1275,6 +1278,7 @@ dependencies = [
"deckcraft-render",
"deckcraft-text",
"egui",
"egui_extras",
"egui_kittest",
"log",
"serde",
@@ -1284,19 +1288,12 @@ dependencies = [
"web-time",
]
[[package]]
name = "deckcraft-vp9"
version = "0.13.1"
dependencies = [
"deckcraft-bitstream",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "deckcraft-web"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"craft-web-api",
"craft-web-host",
"deckcraft-engine",
"deckcraft-ui-egui",
"eframe",
@@ -1507,19 +1504,35 @@ dependencies = [
"block2 0.6.2",
"bytemuck",
"egui",
"image",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
"parking_lot",
"profiling",
"raw-window-handle",
"smithay-clipboard",
"web-time",
"webbrowser",
"windows-sys 0.61.2",
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1587,6 +1600,26 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -2708,7 +2741,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -3487,7 +3520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4403,8 +4436,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
dependencies = [
"bitflags 2.13.2",
"calloop",
"calloop-wayland-source",
"calloop 0.13.0",
"calloop-wayland-source 0.3.0",
"cursor-icon",
"libc",
"log",
@@ -4421,6 +4454,44 @@ dependencies = [
"xkeysym",
]
[[package]]
name = "smithay-client-toolkit"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
dependencies = [
"bitflags 2.13.2",
"calloop 0.14.5",
"calloop-wayland-source 0.4.1",
"cursor-icon",
"libc",
"log",
"memmap2",
"rustix 1.1.5",
"thiserror 2.0.21",
"wayland-backend",
"wayland-client",
"wayland-csd-frame",
"wayland-cursor",
"wayland-protocols",
"wayland-protocols-experimental",
"wayland-protocols-misc",
"wayland-protocols-wlr",
"wayland-scanner",
"xkeysym",
]
[[package]]
name = "smithay-clipboard"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
dependencies = [
"libc",
"smithay-client-toolkit 0.20.0",
"wayland-backend",
]
[[package]]
name = "smol_str"
version = "0.2.2"
@@ -4928,6 +4999,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -5370,6 +5442,32 @@ dependencies = [
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-experimental"
version = "20250721.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-misc"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-plasma"
version = "0.3.12"
@@ -5980,7 +6078,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
"bytemuck",
"calloop",
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
"core-foundation 0.9.4",
@@ -6001,7 +6099,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"smithay-client-toolkit",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
"unicode-segmentation",
@@ -6177,7 +6275,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.13.1"
version = "0.18.1"
dependencies = [
"serde_json",
]
@@ -6423,7 +6521,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -6511,11 +6608,6 @@ name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
+10 -16
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.13.1"
version = "0.18.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -27,16 +27,6 @@ deckcraft-engine = { path = "crates/engine" }
deckcraft-mcp = { path = "crates/mcp" }
deckcraft-ui-egui = { path = "crates/ui-egui" }
deckcraft-media = { path = "crates/media" }
# codecs (ported from FilmCraft, same owner and licence)
deckcraft-bitstream = { path = "crates/bitstream" }
deckcraft-h264 = { path = "crates/h264" }
deckcraft-hevc = { path = "crates/hevc" }
deckcraft-vp9 = { path = "crates/vp9" }
deckcraft-av1 = { path = "crates/av1" }
deckcraft-isobmff = { path = "crates/isobmff" }
deckcraft-matroska = { path = "crates/matroska" }
deckcraft-ogg = { path = "crates/ogg" }
deckcraft-opus = { path = "crates/opus" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -59,11 +49,15 @@ harfrust = "0.13"
vello_cpu = "0.2"
vello_common = { version = "0.2", 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
# craft-media, craft-audio and craft-video share types and stay on one rev.
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "09dcbc225b95232998498a40ca56e24716571d53", version = "=0.1.0", default-features = false }
craft-audio = { git = "https://github.com/storytold/craft-libs", rev = "09dcbc225b95232998498a40ca56e24716571d53", version = "=0.1.0", default-features = false, features = ["opus-decode"] }
# H.264, HEVC, VP9 and AV1 decoders.
craft-video = { git = "https://github.com/storytold/craft-libs", rev = "09dcbc225b95232998498a40ca56e24716571d53", version = "=0.1.0", default-features = false, features = ["h264-decode", "hevc-decode", "vp9-decode", "av1-decode", "threads"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0" }
i_overlay = "9"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
@@ -109,7 +103,7 @@ codegen-units = 16
debug = 0
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.8.2" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.8.2" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+3 -2
View File
@@ -76,8 +76,9 @@ Code dependencies
- symphonia (audio decoding for MP3, AAC, ALAC, FLAC, Vorbis, WAV, AIFF, CAF) is used unmodified
under the Mozilla Public License 2.0; its source is available at
https://github.com/pdeljanov/Symphonia.
- The H.264, HEVC, VP9, AV1, Opus, MP4, Matroska and Ogg crates under crates/ are ported from
FilmCraft (same owner, MIT OR Apache-2.0).
- The H.264, HEVC, VP9 and AV1 crates under crates/ are ported from FilmCraft (same owner,
MIT OR Apache-2.0). The Opus decoder (craft-audio) and the MP4, Matroska and Ogg demuxers
(craft-media) come from craft-libs (same owner, MIT OR Apache-2.0).
- Krilla 0.8.2 (https://github.com/LaurenzV/krilla) is used through craft-pdf under
MIT OR Apache-2.0. craft-libs vendors that version with page/surface unwind recovery
+9 -1
View File
@@ -286,7 +286,15 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | **Presentations and slide shows · you are here** | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+39 -2
View File
@@ -2,6 +2,14 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-11 · **Change:** trivial (the Opus decoder and the Ogg, MP4 and Matroska demuxers come from craft-libs; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: web `?host=parent` bridge opens and saves through a same-origin framing page; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (progress log: fix for a workflow-breaking regression: Ctrl+C/X/V use the system clipboard again, broken for all text and objects from 0.11.1 to 0.15.0; pasted and duplicated placeholders keep their look; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: 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: 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)
@@ -146,6 +154,25 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-11-11 | Media: the Opus decoder and the Ogg, MP4/MOV and Matroska demuxers are craft-audio and craft-media from craft-libs; DeckCraft's own copies and re-export crates are removed |
| 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered |
| 2026-11-03 | Web: the `?host=parent` bridge (as in GridCraft, WordCraft and PdfCraft) lets a same-origin page that frames DeckCraft send a presentation and store its saves; Save writes an opened .pptx back as .pptx under its own name, as on the desktop |
| 2026-11-02 | Slide show on Linux and the BSDs: Presenter View stays off when winit lists exactly one display, as in PowerPoint; if it lists none, the setting is kept. On one display the presenter window covered the show, and the hidden window stalled every frame: slide changes took up to ~1.7 s under Hyprland (#1156) |
| 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order |
| 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves |
| 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged |
| 2026-11-02 | Deleting slides removes their IDs from every custom show; the cleanup is undoable and survives native save/reopen |
| 2026-11-02 | Reused slides scale their geometry and content dimensions to the destination slide, including kept source masters and layouts; the reuse picker still lacks thumbnails |
| 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences |
| 2026-11-02 | PPTX reflection effects keep their authored start position, affine scale/skew, direction and fade, alignment, and rotation flag through native and PPTX saves |
| 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables |
| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) |
| 2026-11-01 | Ctrl+V of a picture copied in a browser (Copy Image) pastes the picture, not a text box with its link and alt text; the link still pastes as text when no picture came with it |
| 2026-11-01 | Copy, Cut, Paste, Duplicate and Alt+drag of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text |
| 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows |
| 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) |
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up |
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
| 2026-10-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) |
| 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) |
@@ -303,7 +330,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) |
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
| 2026-10-10 | Progress docs restructured; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
| 2026-10-07 | v0.1.0 released, signed and notarized; About window with contributor credits (#3); PrintCraft renamed PdfCraft |
@@ -314,6 +341,16 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-11-11 | trivial | Opus and container code moved to craft-libs crates; parity scores unchanged |
| 2026-11-04 | trivial | Ogg Opus recovery duration includes its recovered tail; parity scores unchanged |
| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged |
| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged |
| 2026-11-03 | trivial | Progress log: web `?host=parent` bridge to a framing page; scores unchanged |
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
| 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged |
| 2026-11-01 | trivial | Workflow-breaking regression fixed: the desktop build uses the system clipboard again (eframe `clipboard` feature) |
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged |
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
| 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged |
@@ -413,5 +450,5 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
| 2026-10-10 | major | Restructured to craftrules `standards/progress-docs.md`: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
| 2026-10-10 | major | Restructured: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
| 2026-10-07 | major | Alpha checklist (~80% to a self-defined alpha), breadth ~80%, overall ~62%, ~190 h to full parity |
+5 -1
View File
@@ -16,6 +16,10 @@ path = "src/main.rs"
[dependencies]
deckcraft-engine = { workspace = true }
deckcraft-ui-egui = { workspace = true }
# The page command API (storytold/craft-libs#413): window.deckcraft over the control channel.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -27,4 +31,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget", "UrlSearchParams"] }
+151
View File
@@ -0,0 +1,151 @@
//! `window.deckcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
//!
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
//! `screenshot`, as promises with a timeout). This module is DeckCraft's part: a [`Request`] that
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
//! reply. A call the page gave up on is released ([`Pending`]), so the app skips it if it has not started it, instead of
//! applying a command the page was told had failed.
use std::sync::Arc;
use std::sync::atomic::AtomicU8;
use std::sync::mpsc::{Receiver, TryRecvError};
use deckcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
#[cfg(target_arch = "wasm32")]
const POLL_MS: i32 = 16;
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
/// timeout fired, or the call was abandoned) releases the request: the app skips it if it has not started it (one it has taken still finishes).
struct Pending {
reply: Receiver<Value>,
state: Arc<AtomicU8>,
answered: bool,
}
impl Pending {
/// `Some` once the app has answered or gone away; `None` while the request is still waiting.
fn poll(&mut self) -> Option<Value> {
match self.reply.try_recv() {
Ok(reply) => {
self.answered = true;
Some(reply)
}
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
self.answered = true;
Some(json!({"ok": false, "error": "the app dropped the request"}))
}
}
}
}
impl Drop for Pending {
fn drop(&mut self) {
if !self.answered {
ControlRequest::abandon(&self.state);
}
}
}
#[cfg(target_arch = "wasm32")]
pub use web::install;
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
use std::sync::mpsc::channel;
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use deckcraft_ui_egui::{ControlRequest, SlideApp};
use serde_json::json;
use wasm_bindgen_futures::JsFuture;
use super::{POLL_MS, Pending};
/// The app id in `window.<app>`.
const APP: &str = "deckcraft";
/// Give the app its control queue and put `window.deckcraft` on the page. An existing
/// `window.deckcraft` keeps the functions it already has.
pub fn install(app: &mut SlideApp, ctx: egui::Context) {
let (queue, requests) = channel::<ControlRequest>();
app.control_rx = Some(requests);
let request: Request = Rc::new(move |method, params| {
let (control, reply) = ControlRequest::new(method, params);
let mut pending = Pending { reply, state: control.state.clone(), answered: false };
if queue.send(control).is_err() {
pending.answered = true;
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
}
ctx.request_repaint();
Box::pin(async move {
loop {
if let Some(reply) = pending.poll() {
return reply;
}
sleep(POLL_MS).await;
}
})
});
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
log::warn!("couldn't install window.{APP}: {e:?}");
}
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use super::*;
/// `ControlRequest`'s `state` values (`deckcraft_ui_egui::control`): the app has started it, or its
/// sender gave up.
const TAKEN: u8 = 1;
const ABANDONED: u8 = 2;
fn pending() -> (Pending, ControlRequest) {
let (control, reply) = ControlRequest::new("document.inspect", json!({}));
(Pending { reply, state: control.state.clone(), answered: false }, control)
}
#[test]
fn a_call_the_page_gave_up_on_is_released_so_the_app_skips_it() {
let (waiting, control) = pending();
drop(waiting);
assert_eq!(control.state.load(Ordering::SeqCst), ABANDONED);
assert!(!control.take(), "the app must not run a request the page was told had failed");
}
#[test]
fn an_answered_call_is_not_released() {
let (mut waiting, control) = pending();
assert!(control.take());
control.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default();
assert_eq!(waiting.poll(), Some(json!({"ok": true, "result": 1})));
drop(waiting);
assert_eq!(control.state.load(Ordering::SeqCst), TAKEN);
}
#[test]
fn a_request_the_app_never_runs_waits_until_it_is_dropped() {
let (mut waiting, control) = pending();
assert_eq!(waiting.poll(), None);
drop(control);
assert_eq!(waiting.poll().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away");
}
}
+149
View File
@@ -0,0 +1,149 @@
//! The host bridge, for pages that embed DeckCraft and keep its files (a Nextcloud app, a personal
//! file server). The messages and the two transports are `craft-web-host`'s
//! (storytold/craft-libs), the same contract as the other Craft apps' bridges, with the
//! `deckcraft:` prefix. This module is DeckCraft's part of it.
//!
//! - `?host=parent` (an `<iframe>`) talks to the same-origin parent over `postMessage`;
//! `data-host="page"` on the canvas (the page's own document, no iframe) uses DOM
//! `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `deckcraft:ready` (to the host): sent once the app is running and listening; send a file now. DeckCraft
//! reads bytes, so it says `file: false` and a host sends `bytes`.
//! - `deckcraft:open` (from the host): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`, name a bare file name. It opens like a file picked with File › Open on the
//! desktop: a presentation sent with its name (.pptx, .potx, .ppsx, .deckcraft) has that name
//! as its file, so Save goes back to it; an outline (.txt, .md, .rtf), a file without a name and
//! a damaged file open untitled.
//! - `deckcraft:save` (to the host): `{ name, saveAs, bytes }` (with the bytes as a transferred `ArrayBuffer` over `postMessage`) for
//! every file DeckCraft would otherwise download. The host stores it and reports failures
//! itself. `saveAs` says whether the file becomes the presentation's file:
//! - Save: `false`. It writes the opened file under its own name and format (a .pptx stays a
//! .pptx), as the desktop app does. A name the parent never sent (an untitled presentation, an
//! outline, a file sent without a name, a repaired copy of a damaged file, a file the user
//! picked or dropped) is a new file: `true`, as for Save As, named `<title>.deckcraft` as
//! Save suggests on the desktop.
//! - Save As (File › Save As as .pptx or .deckcraft, Ctrl+Shift+S): `true`. The presentation is
//! now that file; the next Save sends its name with `false`.
//! - Export (PDF, pictures, outline, GIF, handouts, a Save As type that isn't a presentation)
//! and Print: `false`. They are copies; the presentation's file stays the same.
//! - `deckcraft:dirty` whenever unsaved changes appear or go away, `deckcraft:failed` when the
//! app couldn't start or refused a file of 2 GiB or larger.
//!
//! In host mode the app starts with an empty presentation instead of the sample, which gives way to
//! the first file the host sends; the host guards leaving the page, and `?author=` names the user
//! for comments.
use std::cell::RefCell;
use std::rc::Rc;
use craft_web_host::web::{FileData, Host, Message};
use deckcraft_ui_egui::{Services, SlideApp};
/// The app id in the host messages (`deckcraft:ready`).
const APP: &str = "deckcraft";
/// The name `craft-web-host` gives an open request that carries none (it cleans a host's name and
/// falls back to this): no name is no file, so the presentation opens untitled.
const NAMELESS: &str = "document";
/// The largest file a host may send. File › Open on the desktop refuses anything over 2 GiB.
/// On wasm32 a `Vec` cannot hold 2 GiB (`isize::MAX` is one byte less), so a file of exactly
/// 2 GiB is refused too, before its bytes are copied.
const MAX_OPEN_BYTES: f64 = 2_147_483_648.0;
/// The longest user name taken from `?author=` (the preferences' own limit).
const MAX_AUTHOR_CHARS: usize = 256;
#[derive(Clone)]
pub struct Bridge {
host: Rc<Host>,
/// Files the host sent (the name, if it sent one), waiting for the next frame.
opened: Rc<RefCell<Vec<(Option<String>, Vec<u8>)>>>,
}
impl Bridge {
/// The bridge for this page, or `None` when DeckCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
let host = Host::connect(APP, canvas)?;
// An opaque origin (a file:// page in some browsers) can't be a postMessage target.
if host.mode() == craft_web_host::Mode::Parent && web_sys::window()?.location().origin().ok()? == "null" {
return None;
}
Some(Self { host: Rc::new(host), opened: Rc::default() })
}
/// Hand saved and exported files to the host instead of downloading them, and hear the files
/// it sends.
pub fn connect(&self, services: &mut Services, ctx: egui::Context) {
self.listen(ctx);
let bridge = self.clone();
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
// Exports and Print are copies.
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
if let Err(e) = bridge.post_save(name, bytes, false) {
log::error!("couldn't hand {name} to the page: {e}");
}
}));
}
/// `?author=`: the host's name for the user signs new comments.
pub fn set_author(app: &mut SlideApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.prefs.author = author;
}
}
pub fn post_ready(&self) {
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
let _ = self.host.post_failed(error, None);
}
/// Before each frame: open the files the host sent.
pub fn open_files(&self, app: &mut SlideApp) {
let opened = std::mem::take(&mut *self.opened.borrow_mut());
for (name, bytes) in opened {
app.open_from_host(name.as_deref(), &bytes);
}
}
/// After each frame: tell the host when unsaved changes appear or go away (the crate does not
/// repeat a state it already reported).
pub fn report_dirty(&self, app: &SlideApp) {
self.host.post_dirty(app.session.documents().iter().any(|d| d.is_dirty()));
}
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
self.host.post_save(&name, bytes, None, save_as)
}
/// Hear `deckcraft:open` and queue the file for the next frame.
fn listen(&self, ctx: egui::Context) {
let opened = self.opened.clone();
let host = self.host.clone();
self.host.listen(move |message| {
let Message::Open { name, data } = message else { return };
if data.size() >= MAX_OPEN_BYTES {
log::warn!("host file {name} is 2 GiB or larger; not opened");
let _ = host.post_failed(&format!("{name} is 2 GiB or larger, which DeckCraft's web build cannot open"), None);
return;
}
let FileData::Bytes(array) = data else {
log::warn!("host file {name} came as a File; DeckCraft reads bytes only; not opened");
return;
};
// No name is no file: the presentation opens untitled, never under an invented name.
let name = (name != NAMELESS).then_some(name);
opened.borrow_mut().push((name, array.to_vec()));
ctx.request_repaint();
});
}
}
+10 -1
View File
@@ -9,15 +9,24 @@
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
//! asynchronously through `Services::inbox`, applied to the document, slide and operation
//! that started the pick (a dismissed picker or a closed document drops the file);
//! - Save and Export trigger a browser download;
//! - Save and Export trigger a browser download (or go to the framing page with `?host=parent`);
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
//! - preferences are not persisted.
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?blank` starts with an
//! empty presentation instead of the sample deck; `?show` starts the slide show.
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! presentation to open and stores what DeckCraft saves (`host`), and `&author=` names the user
//! for comments.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(any(target_arch = "wasm32", test))]
mod api;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
mod presenter;
#[cfg(target_arch = "wasm32")]
mod web;
+306
View File
@@ -0,0 +1,306 @@
//! Same-origin popup transport. Each window starts its own eframe/WASM runner.
use deckcraft_ui_egui::presenter::{Command, DeltaCache, Host, Snapshot, View};
use std::{
cell::RefCell,
collections::VecDeque,
rc::{Rc, Weak},
};
use wasm_bindgen::{JsCast, closure::Closure};
const MAX_MESSAGE: usize = 16 * 1024 * 1024;
const MAX_COMMANDS: usize = 64;
const PREFIX: &str = "#deckcraft-presenter=";
fn error(e: wasm_bindgen::JsValue) -> String {
format!("{e:?}")
}
fn origin() -> Result<String, String> {
web_sys::window().ok_or("No browser window")?.location().origin().map_err(error)
}
pub fn token() -> Option<String> {
let hash = web_sys::window()?.location().hash().ok()?;
let token = hash.strip_prefix(PREFIX)?;
(!token.is_empty() && token.len() < 128).then(|| token.to_owned())
}
fn same_source(event: &web_sys::MessageEvent, window: &web_sys::Window, expected_origin: &str) -> bool {
event.origin() == expected_origin && event.source().is_some_and(|source| js_sys::Object::is(source.as_ref(), window.as_ref()))
}
fn send(window: &web_sys::Window, origin: &str, value: serde_json::Value) -> Result<(), String> {
let text = serde_json::to_string(&value).map_err(|e| e.to_string())?;
if text.len() > MAX_MESSAGE {
return Err("Presenter message is too large".into());
}
window.post_message(&wasm_bindgen::JsValue::from_str(&text), origin).map_err(error)
}
struct State {
popup: Option<web_sys::Window>,
token: String,
commands: VecDeque<Command>,
ready: bool,
refresh_images: bool,
}
pub struct BrowserHost {
state: Rc<RefCell<State>>,
origin: String,
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
button: Option<(web_sys::HtmlElement, Closure<dyn FnMut(web_sys::Event)>)>,
images: DeltaCache,
}
impl BrowserHost {
pub fn new(ctx: egui::Context) -> Result<Self, String> {
let window = web_sys::window().ok_or("No browser window")?;
let origin = origin()?;
let state = Rc::new(RefCell::new(State { popup: None, token: String::new(), commands: VecDeque::new(), ready: false, refresh_images: true }));
let weak = Rc::downgrade(&state);
let expected = origin.clone();
let listener = Closure::new(move |event: web_sys::MessageEvent| {
let Some(state) = weak.upgrade() else { return };
let Ok(mut state) = state.try_borrow_mut() else { return };
let Some(popup) = state.popup.as_ref() else { return };
if !same_source(&event, popup, &expected) {
return;
}
let Some(text) = event.data().as_string().filter(|s| s.len() <= 8192) else { return };
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
if value.get("token").and_then(|v| v.as_str()) != Some(state.token.as_str()) {
return;
}
match value.get("kind").and_then(|v| v.as_str()) {
Some("ready") => {
state.ready = true;
state.refresh_images = true;
}
Some("command") if state.commands.len() < MAX_COMMANDS => {
if let Some(command) = value.get("command").and_then(|v| serde_json::from_value(v.clone()).ok()) {
state.commands.push_back(command);
}
}
_ => {}
}
ctx.request_repaint();
});
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
Ok(Self { state, origin, listener, button: None, images: DeltaCache::default() })
}
fn remove_button(&mut self) {
if let Some((button, callback)) = self.button.take() {
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
button.remove();
}
}
fn recovery_button(&mut self) {
if self.button.is_some() {
return;
}
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(body) = document.body() else { return };
let Ok(element) = document.create_element("button") else { return };
let Ok(button) = element.dyn_into::<web_sys::HtmlElement>() else { return };
button.set_text_content(Some("Open presenter window"));
let _ = button.set_attribute("style", "position:fixed;top:12px;right:12px;z-index:9999;padding:12px;color:white;background:#333;border:1px solid #aaa;border-radius:4px;cursor:pointer");
let weak: Weak<RefCell<State>> = Rc::downgrade(&self.state);
let button_copy = button.clone();
let callback = Closure::new(move |_event: web_sys::Event| {
if let Some(state) = weak.upgrade() {
match open_popup(&state) {
Ok(()) => {
button_copy.set_text_content(Some("Opening presenter window…"));
}
Err(_) => {
button_copy.set_text_content(Some("Popup blocked. Allow popups, then click to retry."));
}
}
}
});
if button.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref()).is_ok() && body.append_child(&button).is_ok() {
self.button = Some((button, callback));
} else {
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
button.remove();
}
}
}
fn open_popup(state: &Rc<RefCell<State>>) -> Result<(), String> {
let window = web_sys::window().ok_or("No browser window")?;
let mut state = state.try_borrow_mut().map_err(|_| "Presenter is busy")?;
if let Some(popup) = &state.popup
&& !popup.closed().unwrap_or(true)
{
let _ = popup.focus();
return Ok(());
}
let token = format!("{}-{}", js_sys::Date::now(), js_sys::Math::random());
let url = web_sys::Url::new(&window.location().href().map_err(error)?).map_err(error)?;
url.set_hash(&format!("{PREFIX}{token}"));
let popup = window
.open_with_url_and_target_and_features(&url.href(), "_blank", "popup,width=1100,height=700")
.map_err(error)?
.ok_or("Presenter popup was blocked. Use Open presenter window to retry.")?;
if popup.closed().unwrap_or(true) {
return Err("Browser isolation prevented connecting the presenter window".into());
}
state.popup = Some(popup);
state.token = token;
state.commands.clear();
state.ready = false;
Ok(())
}
impl Host for BrowserHost {
fn open(&mut self) -> Result<(), String> {
self.close();
let result = open_popup(&self.state);
if result.is_err() {
self.recovery_button();
}
result
}
fn is_open(&self) -> bool {
self.state.borrow().popup.as_ref().is_some_and(|w| !w.closed().unwrap_or(true))
}
fn commands(&mut self) -> Vec<Command> {
self.state.borrow_mut().commands.drain(..).collect()
}
fn update(&mut self, snapshot: Snapshot) {
if !self.is_open() {
self.recovery_button();
return;
}
self.remove_button();
let mut state = self.state.borrow_mut();
if !state.ready {
return;
}
if let Some(popup) = state.popup.clone() {
let token = state.token.clone();
let sent = self.images.send(snapshot, &token, state.refresh_images, |delta, current_changed, next_changed| {
send(
&popup,
&self.origin,
serde_json::json!({"kind":"snapshot", "token":token, "snapshot":delta,
"currentChanged":current_changed, "nextChanged":next_changed}),
)
});
if sent.is_ok() {
state.refresh_images = false;
}
}
}
fn close(&mut self) {
self.remove_button();
let mut state = self.state.borrow_mut();
if let Some(window) = state.popup.take() {
let _ = window.close();
}
state.commands.clear();
state.ready = false;
state.token.clear();
}
}
impl Drop for BrowserHost {
fn drop(&mut self) {
self.close();
if let Some(window) = web_sys::window() {
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
}
}
}
pub struct Popup {
token: String,
origin: String,
opener: web_sys::Window,
snapshot: Rc<RefCell<Option<Snapshot>>>,
last_received: Rc<RefCell<f64>>,
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
view: View,
}
impl Popup {
pub fn new(token: String, ctx: egui::Context) -> Result<Self, String> {
let window = web_sys::window().ok_or("No browser window")?;
// `opener` is a WindowProxy from another realm; instanceof Window can fail across
// realms. The browser API supplies a WindowProxy or null, and messages still verify
// exact source identity and origin before any data is accepted.
let opener = window.opener().map_err(error)?;
if opener.is_null() || opener.is_undefined() {
return Err("The audience window is unavailable".into());
}
let opener: web_sys::Window = opener.unchecked_into();
let origin = origin()?;
let snapshot: Rc<RefCell<Option<Snapshot>>> = Rc::new(RefCell::new(None));
let last_received = Rc::new(RefCell::new(js_sys::Date::now()));
let received = last_received.clone();
let target = snapshot.clone();
let expected_origin = origin.clone();
let expected_opener = opener.clone();
let expected_token = token.clone();
let listener = Closure::new(move |event: web_sys::MessageEvent| {
if !same_source(&event, &expected_opener, &expected_origin) {
return;
}
let Some(text) = event.data().as_string().filter(|s| s.len() <= MAX_MESSAGE) else { return };
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
if value.get("token").and_then(|v| v.as_str()) != Some(expected_token.as_str())
|| value.get("kind").and_then(|v| v.as_str()) != Some("snapshot")
{
return;
}
if let Some(mut data) = value.get("snapshot").and_then(|v| serde_json::from_value::<Snapshot>(v.clone()).ok()) {
if let Some(previous) = target.borrow().as_ref() {
if value.get("currentChanged").and_then(|v| v.as_bool()) == Some(false) {
data.current = previous.current.clone();
}
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
data.next = previous.next.clone();
}
if data.slides.is_none() {
data.slides = previous.slides.clone();
}
}
*target.borrow_mut() = Some(data);
*received.borrow_mut() = js_sys::Date::now();
ctx.request_repaint();
}
});
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
if let Some(document) = window.document() {
document.set_title("Presenter View — DeckCraft");
}
let popup = Self { token, origin, opener, snapshot, last_received, listener, view: View::default() };
send(&popup.opener, &popup.origin, serde_json::json!({"kind":"ready","token":popup.token}))?;
Ok(popup)
}
}
impl eframe::App for Popup {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let snapshot = self.snapshot.borrow();
let notes = snapshot.as_ref().map_or("", |snapshot| snapshot.notes.as_str());
if !self.view.prepare(ui.ctx(), notes) {
return;
}
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
if self.opener.closed().unwrap_or(true) {
ui.heading("The audience window has closed.");
return;
}
if js_sys::Date::now() - *self.last_received.borrow() > 10_000.0 {
ui.heading("The audience window is not responding.");
ui.label("Return to the audience window to resume the connection.");
return;
}
egui::CentralPanel::default().show(ui, |ui| {
if let Some(snapshot) = snapshot.as_ref() {
for command in self.view.ui(ui, snapshot) {
let _ = send(&self.opener, &self.origin, serde_json::json!({"kind":"command","token":self.token,"command":command}));
}
} else {
ui.label("Waiting for the audience window…");
}
});
}
}
impl Drop for Popup {
fn drop(&mut self) {
if let Some(window) = web_sys::window() {
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
}
}
}
+74 -7
View File
@@ -24,8 +24,18 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing DeckCraft,
// opens presentations in it and stores what it saves (`host`); `data-host="page"` on the
// canvas opts a page in that runs DeckCraft in its own document. The presenter popup is
// never the framed page.
let hosted = crate::presenter::token().is_none();
let bridge = if hosted { crate::host::Bridge::detect(&canvas) } else { None };
if hosted && bridge.is_none() && query_param("host").as_deref() == Some("parent") {
log::warn!("?host=parent needs a framing page on the same origin; saves download instead");
}
let app_bridge = bridge.clone();
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
if query_has("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
@@ -39,23 +49,48 @@ pub fn start() {
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
}
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
if let Some(token) = crate::presenter::token() {
let presenter = crate::presenter::Popup::new(token, cc.egui_ctx.clone()).map_err(std::io::Error::other)?;
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
}
let inbox: Inbox = Inbox::default();
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let q = query();
if !q.contains("blank") {
let mut services = services(inbox.clone(), cc.egui_ctx.clone());
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, cc.egui_ctx.clone());
}
let mut app = SlideApp::new(Session::new(), services);
// In host mode the parent sends the presentation to edit; no sample meanwhile.
if app_bridge.is_some() {
if let Err(e) = app.start_for_host() {
log::error!("new presentation: {e}");
}
} else if !query_has("blank") {
if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
log::error!("sample deck: {e}");
}
} else if let Err(e) = app.run("file.new", serde_json::json!({})) {
log::error!("new presentation: {e}");
}
if q.contains("show") {
if query_has("show") {
app.start_show(0, false);
}
Ok(Box::new(WebShell { app, inbox }))
if app_bridge.is_some()
&& let Some(author) = query_param("author")
{
crate::host::Bridge::set_author(&mut app, &author);
}
crate::api::install(&mut app, cc.egui_ctx.clone());
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
}),
)
.await;
// Ready only once the runner is up: starting can still fail after the app is made.
if let Some(bridge) = &bridge {
match &result {
Ok(()) => bridge.post_ready(),
Err(e) => bridge.post_failed(&format!("{e:?}")),
}
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@@ -69,17 +104,41 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
/// Whether the URL has the flag `key` (`?show`, `&blank=1`). A whole parameter, never a substring:
/// `?author=` is free text.
fn query_has(key: &str) -> bool {
web_sys::UrlSearchParams::new_with_str(&query()).is_ok_and(|q| q.has(key))
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
struct WebShell {
app: SlideApp,
inbox: Inbox,
/// The parent page that keeps the files (`?host=parent`).
bridge: Option<crate::host::Bridge>,
}
impl eframe::App for WebShell {
/// Delegate one eframe logic pass to the wrapped application.
/// Delegate one eframe logic pass to the wrapped application, opening the files the parent
/// page sent first and telling it about unsaved changes after.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if let Some(bridge) = &self.bridge {
bridge.open_files(&mut self.app);
}
self.app.logic(ctx);
if let Some(bridge) = &self.bridge {
bridge.report_dirty(&self.app);
}
if self.app.show.is_none()
&& let Some(host) = self.app.services.presenter.as_mut()
{
host.close();
}
}
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
@@ -118,7 +177,15 @@ impl eframe::App for WebShell {
/// back to `open_async` (browser picker → inbox) and `download`.
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
let presenter = match crate::presenter::BrowserHost::new(ctx.clone()) {
Ok(host) => Some(Box::new(host) as Box<dyn deckcraft_ui_egui::presenter::Host>),
Err(error) => {
log::error!("Presenter window: {error}");
None
}
};
Services {
presenter,
open_async: Some(Box::new(move |purpose: &str, request: u64| {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
+3 -1
View File
@@ -14,7 +14,9 @@ workspace = true
deckcraft-engine = { workspace = true }
deckcraft-ui-egui = { workspace = true }
deckcraft-media = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
# `clipboard` is the OS clipboard (Ctrl+C/X/V with other apps). Without it egui simulates one inside
# the app; the egui 0.37 migration (#1350) dropped it from this list.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+20 -3
View File
@@ -148,7 +148,19 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
// Messages can embed document-controlled text (a part name, a relationship id): control
// characters other than tab are escaped and each continuation line is marked, so such text
// cannot start a line that looks like a record of its own.
let mut text = String::with_capacity(message.len());
for c in message.chars() {
match c {
'\n' => text.push_str("\n | "),
'\t' => text.push(c),
c if c.is_control() => text.extend(c.escape_default()),
c => text.push(c),
}
}
format!("{ts} {level:<5} [{thread}] {target}: {text}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
@@ -446,8 +458,13 @@ mod tests {
),
"2026-10-08T07:59:17.728Z WARN [main] deckcraft_pptx::opc: pptx: skipping oversized part ppt/media/image1.png\n"
);
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
// A multi-line message keeps its lines, each continuation marked; the record still ends in
// exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n | b\n"));
// Injected line breaks and escape sequences cannot forge a record.
let line = format_line("t", log::Level::Warn, "w", "x", "part a\r\n2026 ERROR [m] y: forged\x1b[2J");
assert_eq!(line.matches('\n').count(), 2);
assert!(!line.contains('\r') && !line.contains('\x1b'));
}
#[test]
+54 -5
View File
@@ -51,6 +51,9 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
save_prefs(&self.0);
// The window is closing for good: a ready update's helper starts now and installs it once
// the process has exited.
self.0.start_pending_update();
// macOS (#279): end the process here, before eframe tears the window down. AccessKit's
// macOS adapter subclasses the window's content view and restores its class on teardown;
// AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
@@ -60,6 +63,8 @@ impl eframe::App for App {
// nothing after it is needed: the preferences are saved and the log file is unbuffered.
// `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117.
#[cfg(target_os = "macos")]
self.0.cleanup_print_previews();
#[cfg(target_os = "macos")]
std::process::exit(0);
}
}
@@ -101,7 +106,13 @@ fn save_prefs(app: &SlideApp) {
}
}
fn services() -> Services {
/// The desktop services. `native_wayland`: the window is a Wayland client (not Xwayland).
fn services(native_wayland: bool) -> Services {
// On native Wayland, egui-winit owns the clipboard through winit's data device, which carries
// text and PNG but no HTML. `pasteboard` (arboard, built for X11 only) would put a copy on the
// Xwayland selection instead and skip egui's Wayland claim, so Ctrl+V would read something
// else. There, text goes through egui and pictures arrive as `PasteImage` events.
let pasteboard = !native_wayland;
Services {
pick_open: Some(Box::new(|purpose: &str| {
let d = rfd::FileDialog::new();
@@ -124,9 +135,9 @@ fn services() -> Services {
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
clipboard_image: Some(Box::new(pasteboard::image_png)),
clipboard_html: Some(Box::new(pasteboard::html)),
clipboard_set_html: Some(Box::new(pasteboard::set_html)),
clipboard_image: pasteboard.then(|| Box::new(pasteboard::image_png) as Box<dyn FnMut() -> Option<Vec<u8>>>),
clipboard_html: pasteboard.then(|| Box::new(pasteboard::html) as Box<dyn FnMut() -> Option<String>>),
clipboard_set_html: pasteboard.then(|| Box::new(pasteboard::set_html) as Box<dyn FnMut(&str, &str) -> bool>),
// Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently.
audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO")
.is_none()
@@ -135,6 +146,14 @@ fn services() -> Services {
}
}
/// Displays winit lists for `window`, counting an output Wayland lists twice once. `None` when it
/// lists none: Presenter View then stays as it was.
#[cfg(all(unix, not(target_os = "macos")))]
fn display_count(window: &winit::window::Window) -> Option<usize> {
let n = window.available_monitors().collect::<std::collections::BTreeSet<_>>().len();
(n > 0).then_some(n)
}
const APP_ID: &str = "ai.storyteller.deckcraft";
const USAGE: &str = "\
@@ -245,6 +264,11 @@ fn main() -> eframe::Result {
false
}
};
// `Default` lets winit choose, and winit takes Wayland whenever `WAYLAND_DISPLAY` is set.
#[cfg(all(unix, not(target_os = "macos")))]
let native_wayland = !x11 && std::env::var_os("WAYLAND_DISPLAY").is_some_and(|w| !w.is_empty());
#[cfg(not(all(unix, not(target_os = "macos"))))]
let native_wayland = false;
#[cfg(all(unix, not(target_os = "macos")))]
if x11 {
options.event_loop_builder = Some(Box::new(|b| {
@@ -266,7 +290,12 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
let mut app = SlideApp::new(Session::new(), services());
let mut app = SlideApp::new(Session::new(), services(native_wayland));
// Presenter View asks winit how many displays there are when a show starts.
#[cfg(all(unix, not(target_os = "macos")))]
if let Some(window) = cc.winit_window().cloned() {
app.services.displays = Some(Box::new(move || display_count(&window)));
}
load_prefs(&mut app);
app.enable_update_checks();
// AutoRecover: reopen what a previous run left unsaved, then keep it current.
@@ -333,6 +362,26 @@ mod tests {
assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
}
/// The egui 0.37 migration (#1350) left `clipboard` out of eframe's features. egui then
/// simulated a clipboard inside the app: nothing pasted from other apps, and Ctrl+C/Ctrl+V
/// lost text that went out as HTML. Keep the OS clipboard on.
#[test]
fn eframe_uses_the_os_clipboard() {
let manifest = include_str!("../Cargo.toml");
let eframe = manifest.lines().find(|l| l.starts_with("eframe = ")).unwrap_or_default();
assert!(eframe.contains("\"clipboard\""), "eframe must enable the OS clipboard: {eframe}");
}
/// On native Wayland, the X11-only pasteboard would bypass egui's Wayland clipboard; elsewhere
/// it carries HTML and pictures beside egui's text.
#[test]
fn the_pasteboard_stays_off_native_wayland() {
let s = services(true);
assert!(s.clipboard_html.is_none() && s.clipboard_set_html.is_none() && s.clipboard_image.is_none());
let s = services(false);
assert!(s.clipboard_html.is_some() && s.clipboard_set_html.is_some() && s.clipboard_image.is_some());
}
#[test]
fn help_exits_instead_of_opening_a_file() {
assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help));
+7 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -71,3 +68,8 @@ real_name = "Sofia Alves Figueredo"
[people.dylanmtaylor]
sync_github_name = true
[people.marshiyar]
display_name = "Arshiya"
real_name = "Arshiya Rahgozar"
sync_github_name = false
+59 -29
View File
@@ -1,5 +1,7 @@
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
use std::collections::{HashMap, HashSet, VecDeque};
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
@@ -15,48 +17,66 @@ fn text_of(s: &Shape) -> String {
/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on
/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used
/// once; top-level shapes only (groups move whole).
///
/// Each pass compares a key of the two shapes, so the shapes of `b` are indexed by key once per
/// pass: matching costs the sum of the two shape lists, not their product.
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
let mut used_a = vec![false; a.shapes.len()];
let mut used_b = vec![false; b.shapes.len()];
let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [
&|x, y| x.name.starts_with("!!") && x.name == y.name,
&|x, y| !x.name.is_empty() && x.name == y.name,
&|x, y| {
if x.kind_name() != y.kind_name() {
return false;
// Each shape's text, worked out once rather than inside every comparison.
let text_a: Vec<String> = a.shapes.iter().map(text_of).collect();
let text_b: Vec<String> = b.shapes.iter().map(text_of).collect();
let forced = |s: &Shape, _: &str| (s.name.starts_with("!!")).then(|| MorphKey::Name(s.name.clone()));
let named = |s: &Shape, _: &str| (!s.name.is_empty()).then(|| MorphKey::Name(s.name.clone()));
let same_text = |s: &Shape, t: &str| (!t.trim().is_empty()).then(|| MorphKey::Text(s.kind_name(), t.to_string()));
let same_geom = |s: &Shape, t: &str| match s.geom.preset_name() {
Some(g) if t.trim().is_empty() => Some(MorphKey::Geom(s.kind_name(), g.to_string())),
_ => None,
};
let passes: [&dyn Fn(&Shape, &str) -> Option<MorphKey>; 4] = [&forced, &named, &same_text, &same_geom];
for key in passes {
// Unused shapes of `b` by key, in slide order: the front of each queue is the first unused
// match, which is the one a search through `b` would find.
let mut index: HashMap<MorphKey, VecDeque<usize>> = HashMap::new();
for (j, sb) in b.shapes.iter().enumerate() {
if used_b.get(j).copied().unwrap_or(true) {
continue;
}
let tx = text_of(x);
!tx.trim().is_empty() && tx == text_of(y)
},
&|x, y| {
x.kind_name() == y.kind_name()
&& x.geom.preset_name().is_some()
&& x.geom.preset_name() == y.geom.preset_name()
&& text_of(x).trim().is_empty()
&& text_of(y).trim().is_empty()
},
];
for pass in passes {
if let Some(k) = key(sb, text_b.get(j).map_or("", String::as_str)) {
index.entry(k).or_default().push_back(j);
}
}
if index.is_empty() {
continue;
}
for (i, sa) in a.shapes.iter().enumerate() {
if used_a.get(i).copied().unwrap_or(true) {
continue;
}
let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb));
if let Some((j, sb)) = found {
if let Some(u) = used_a.get_mut(i) {
*u = true;
}
if let Some(u) = used_b.get_mut(j) {
*u = true;
}
pairs.push((sa.id, sb.id));
let Some(k) = key(sa, text_a.get(i).map_or("", String::as_str)) else { continue };
let Some(j) = index.get_mut(&k).and_then(VecDeque::pop_front) else { continue };
let Some(sb) = b.shapes.get(j) else { continue };
if let Some(u) = used_a.get_mut(i) {
*u = true;
}
if let Some(u) = used_b.get_mut(j) {
*u = true;
}
pairs.push((sa.id, sb.id));
}
}
pairs
}
/// What one [`morph_pairs`] pass matches shapes on.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum MorphKey {
Name(String),
Text(&'static str, String),
Geom(&'static str, String),
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
let v = a + (b - a) * t;
if v.is_finite() {
@@ -256,13 +276,23 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
f.shapes.push((sh, old));
id
};
// Lookups by id, built once (the first shape or pair with an id wins, as a search would).
let paired_a: HashSet<ShapeId> = pairs.iter().map(|(ia, _)| *ia).collect();
let mut partner: HashMap<ShapeId, ShapeId> = HashMap::with_capacity(pairs.len());
for (ia, ib) in &pairs {
partner.entry(*ib).or_insert(*ia);
}
let mut a_by_id: HashMap<ShapeId, &Shape> = HashMap::with_capacity(a.shapes.len());
for x in &a.shapes {
a_by_id.entry(x.id).or_insert(x);
}
for sa in &a.shapes {
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
if !paired_a.contains(&sa.id) {
push(sa.clone(), true, 1.0 - s);
}
}
for sb in &b.shapes {
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
let pair = partner.get(&sb.id).and_then(|ia| a_by_id.get(ia).copied());
let Some(sa) = pair else {
if !hidden_b(sb.id) {
push(sb.clone(), false, s);
+55
View File
@@ -1155,3 +1155,58 @@ fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
assert_eq!(tl.effects().len(), 6_000);
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
}
#[test]
fn morph_pairs_take_the_first_unused_match_in_slide_order() {
let mut a = Slide::default();
let mut b = Slide::default();
a.shapes = vec![shape(1, "Same"), shape(2, "Same"), shape(3, "Other")];
b.shapes = vec![shape(11, "Same"), shape(12, "Same"), shape(13, "Same")];
// By name first, in slide order; the shape of `a` left over pairs by kind and geometry with
// the shape of `b` left over.
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(11)), (ShapeId(2), ShapeId(12)), (ShapeId(3), ShapeId(13))]);
}
#[test]
fn morph_of_large_slides_stays_linear() {
let n = 20_000u32;
let text = |i: u32, side: &str| Some(TextBody::from_text(&format!("{side} {i}")));
let mut a = Slide::default();
let mut b = Slide::default();
// Text no shape on the other slide shares: every pass looks for a partner and finds none.
a.shapes = (0..n).map(|i| Shape { text: text(i, "a"), ..shape(i + 1, "") }).collect();
b.shapes = (0..n).map(|i| Shape { text: text(i, "b"), ..shape(n + i + 1, "") }).collect();
// One named pair at opposite ends.
a.shapes[0].name = "Logo".into();
b.shapes[n as usize - 1].name = "Logo".into();
// No wall-clock bound (it would flake on a loaded runner): at this size a quadratic pairing
// takes minutes, so a regression shows as a test that hangs rather than a failed assertion.
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2 * n))]);
let p = deckcraft_model::defaults::new_presentation(None);
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
// Every shape fades in place; the named pair differs in text, so it cross-fades as two.
assert_eq!(f.shapes.len(), 2 * n as usize);
}
#[test]
fn paragraph_states_of_a_large_build_stay_linear() {
let n = 20_000usize;
let mut s = slide_with(vec![]);
let mut body = TextBody::from_text(&vec!["line"; n].join("\n"));
// One first-level paragraph owning every other paragraph.
for p in body.paragraphs.iter_mut().skip(1) {
p.level = 1;
}
s.shapes[0].text = Some(body);
let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick);
a.text_build = TextBuild::ByParagraph;
s.animations = vec![a];
let tl = Timeline::new(&s, W, H);
// No wall-clock bound, as above: a per-paragraph scan of the slide would take minutes here.
assert_eq!(tl.paragraph_targets(ShapeId(1)).len(), n);
for p in 0..n {
assert!(!tl.state(ShapeId(1), Some(p), 0, 0.0).visible);
assert!(tl.state(ShapeId(1), Some(p), 1, 0.0).visible);
}
assert_eq!(tl.animated_shapes(), [ShapeId(1)]);
}
+47 -12
View File
@@ -103,6 +103,41 @@ pub struct Timeline {
/// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent:
/// (shape, member paragraph, owner paragraph).
groups: Vec<(ShapeId, usize, usize)>,
/// Lookups built with the timeline, so evaluating a shape or paragraph costs its own effects
/// rather than a scan of every effect, group and colour on the slide.
index: TimelineIndex,
}
#[derive(Clone, Debug, Default)]
struct TimelineIndex {
/// Effects (indices into `effects`, in order) by target shape and paragraph.
by_target: HashMap<(ShapeId, Option<usize>), Vec<usize>>,
/// Effects (indices into `effects`, in order) by target shape.
by_shape: HashMap<ShapeId, Vec<usize>>,
/// Owner paragraph of each member paragraph (the first group recorded wins).
owner: HashMap<(ShapeId, usize), usize>,
/// Groups (member, owner) by shape, in order.
groups_by_shape: HashMap<ShapeId, Vec<(usize, usize)>>,
/// Base colour by shape (the first recorded wins).
color: HashMap<ShapeId, Rgba>,
}
impl TimelineIndex {
fn build(effects: &[Effect], groups: &[(ShapeId, usize, usize)], colors: &[(ShapeId, Rgba)]) -> TimelineIndex {
let mut ix = TimelineIndex::default();
for (i, e) in effects.iter().enumerate() {
ix.by_target.entry((e.timing.shape, e.timing.paragraph)).or_default().push(i);
ix.by_shape.entry(e.timing.shape).or_default().push(i);
}
for &(shape, member, owner) in groups {
ix.owner.entry((shape, member)).or_insert(owner);
ix.groups_by_shape.entry(shape).or_default().push((member, owner));
}
for &(shape, c) in colors {
ix.color.entry(shape).or_insert(c);
}
ix
}
}
enum Status {
@@ -324,6 +359,7 @@ impl Timeline {
};
tl.colors.push((id, c));
}
tl.index = TimelineIndex::build(&tl.effects, &tl.groups, &tl.colors);
tl
}
@@ -413,8 +449,12 @@ impl Timeline {
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec<usize> {
let mut v: Vec<usize> = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect();
v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::<Vec<_>>());
let mine = self.index.by_shape.get(&shape).map_or(&[][..], Vec::as_slice);
let mut v: Vec<usize> = mine.iter().filter_map(|&i| self.effects.get(i)).filter_map(|e| e.timing.paragraph).collect();
let owners: HashSet<usize> = v.iter().copied().collect();
if let Some(groups) = self.index.groups_by_shape.get(&shape) {
v.extend(groups.iter().filter(|(_, owner)| owners.contains(owner)).map(|(member, _)| *member));
}
v.sort_unstable();
v.dedup();
v
@@ -422,13 +462,8 @@ impl Timeline {
/// Shapes with any effect.
pub fn animated_shapes(&self) -> Vec<ShapeId> {
let mut v: Vec<ShapeId> = Vec::new();
for e in &self.effects {
if !v.contains(&e.timing.shape) {
v.push(e.timing.shape);
}
}
v
let mut seen: HashSet<ShapeId> = HashSet::new();
self.effects.iter().map(|e| e.timing.shape).filter(|s| seen.insert(*s)).collect()
}
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
@@ -456,9 +491,9 @@ impl Timeline {
triggers: &[TriggerPlay],
stamps: &[StepStamp],
) -> AnimState {
let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let para = para.map(|p| self.index.owner.get(&(shape, p)).copied().unwrap_or(p));
let mine = self.index.by_target.get(&(shape, para)).map_or(&[][..], Vec::as_slice).iter().filter_map(|&i| self.effects.get(i));
let base_color = self.index.color.get(&shape).copied().unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let mut st = AnimState::default();
// Initial visibility comes from the first effect in the Animation Pane order.
let mut ordered: Vec<&Effect> = mine.collect();
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "deckcraft-av1"
description = "Clean-room AV1 decoder (AV1 Bitstream & Decoding Process Specification v1.0.0 with Errata 1)"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[features]
default = ["threads"]
threads = ["dep:rayon"]
[dependencies]
deckcraft-bitstream = { workspace = true }
thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
-124
View File
@@ -1,124 +0,0 @@
//! MSB-first bit reader for OBU headers and the uncompressed frame header (spec 4.10 descriptors).
use crate::{Error, Result};
#[derive(Clone)]
pub(crate) struct BitReader<'a> {
data: &'a [u8],
/// Position in bits.
pos: usize,
}
impl<'a> BitReader<'a> {
pub fn new(data: &'a [u8]) -> Self {
BitReader { data, pos: 0 }
}
/// `get_position()`: bits consumed.
pub fn position(&self) -> usize {
self.pos
}
pub fn byte_pos(&self) -> usize {
self.pos.div_ceil(8)
}
#[inline]
pub fn bit(&mut self) -> Result<u32> {
let byte = *self.data.get(self.pos >> 3).ok_or(Error::Truncated)?;
let b = (byte >> (7 - (self.pos & 7))) & 1;
self.pos += 1;
Ok(b as u32)
}
/// `f(n)`, n ≤ 32.
pub fn f(&mut self, n: u32) -> Result<u32> {
let mut x: u64 = 0;
for _ in 0..n {
x = (x << 1) | self.bit()? as u64;
}
Ok(x as u32)
}
pub fn flag(&mut self) -> Result<bool> {
Ok(self.bit()? == 1)
}
/// `uvlc()`
pub fn uvlc(&mut self) -> Result<u32> {
let mut leading = 0u32;
while self.bit()? == 0 {
leading += 1;
if leading > 40 {
return Err(Error::Invalid("uvlc"));
}
}
if leading >= 32 {
return Ok(u32::MAX);
}
let v = self.f(leading)?;
Ok(v + ((1u64 << leading) - 1) as u32)
}
/// `le(n)`
pub fn le(&mut self, n: u32) -> Result<u32> {
let mut t = 0u32;
for i in 0..n {
t |= self.f(8)? << (8 * i);
}
Ok(t)
}
/// `leb128()`
pub fn leb128(&mut self) -> Result<u64> {
let mut v = 0u64;
for i in 0..8 {
let b = self.f(8)? as u64;
v |= (b & 0x7f) << (7 * i);
if b & 0x80 == 0 {
break;
}
}
Ok(v)
}
/// `su(n)`
pub fn su(&mut self, n: u32) -> Result<i32> {
let v = self.f(n)? as i64;
let sign = 1i64 << (n - 1);
Ok(if v & sign != 0 { (v - 2 * sign) as i32 } else { v as i32 })
}
/// `ns(n)`
pub fn ns(&mut self, n: u32) -> Result<u32> {
if n <= 1 {
return Ok(0);
}
let w = 32 - n.leading_zeros(); // FloorLog2(n) + 1
let m = (1u32 << w) - n;
let v = self.f(w - 1)?;
if v < m {
return Ok(v);
}
let extra = self.bit()?;
Ok((v << 1) - m + extra)
}
/// `byte_alignment()`
pub fn byte_align(&mut self) {
self.pos = self.pos.div_ceil(8) * 8;
}
}
/// Read a leb128 from a byte slice; returns (value, bytes used).
pub(crate) fn leb128(data: &[u8]) -> Result<(u64, usize)> {
let mut v = 0u64;
for i in 0..8 {
let b = *data.get(i).ok_or(Error::Truncated)? as u64;
v |= (b & 0x7f) << (7 * i);
if b & 0x80 == 0 {
return Ok((v, i + 1));
}
}
Ok((v, 8))
}
-314
View File
@@ -1,314 +0,0 @@
//! CDEF (7.15).
use crate::frame::{FrameBuf, Plane};
use crate::spec_tables::{CDEF_DIRECTIONS, CDEF_PRI_TAPS, CDEF_SEC_TAPS, CDEF_UV_DIR, DIV_TABLE};
use crate::state::FrameState;
/// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame
/// equals CurrFrame).
pub(crate) fn apply(fs: &FrameState) -> Option<FrameBuf> {
let fh = &fs.fh;
if fh.coded_lossless || fh.allow_intrabc || !fs.seq.enable_cdef {
return None;
}
let (mi_rows, mi_cols) = (fh.mi_rows as usize, fh.mi_cols as usize);
let mut out = fs.cur.clone();
let mut any = false;
let mut r = 0;
while r < mi_rows {
let mut c = 0;
while c < mi_cols {
let idx = fs.cdef_idx(r & !15, c & !15);
if idx >= 0 && cdef_block(fs, &mut out, r, c, idx as usize) {
any = true;
}
c += 2;
}
r += 2;
}
any.then_some(out)
}
fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usize) -> bool {
let mi = &fs.mi;
let sk = |rr: usize, cc: usize| -> bool { if rr < mi.rows && cc < mi.cols { mi.skip[mi.idx(rr, cc)] } else { true } };
let skip = sk(r, c) && sk(r + 1, c) && sk(r, c + 1) && sk(r + 1, c + 1);
if skip {
return false;
}
let coeff_shift = fs.bit_depth - 8;
let (y_dir, var) = direction(fs, r, c);
let cp = &fs.fh.cdef;
let mut pri = (cp.y_pri[idx] << coeff_shift) as i32;
let sec = (cp.y_sec[idx] << coeff_shift) as i32;
let dir = if pri == 0 { 0 } else { y_dir };
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);
if fs.num_planes == 1 {
return true;
}
let pri = (cp.uv_pri[idx] << coeff_shift) as i32;
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);
true
}
fn direction(fs: &FrameState, r: usize, c: usize) -> (usize, i32) {
let mut cost = [0i32; 8];
let mut partial = [[0i32; 15]; 8];
let x0 = c << 2;
let y0 = r << 2;
let pl = &fs.cur.planes[0];
let sh = fs.bit_depth - 8;
for i in 0..8 {
let row = pl.row(y0 + i);
for j in 0..8 {
let x = (row[x0 + j] as i32 >> sh) - 128;
partial[0][i + j] += x;
partial[1][i + j / 2] += x;
partial[2][i] += x;
partial[3][3 + i - j / 2] += x;
partial[4][7 + i - j] += x;
partial[5][3 - i / 2 + j] += x;
partial[6][j] += x;
partial[7][i / 2 + j] += x;
}
}
for i in 0..8 {
cost[2] += partial[2][i] * partial[2][i];
cost[6] += partial[6][i] * partial[6][i];
}
cost[2] *= DIV_TABLE[8] as i32;
cost[6] *= DIV_TABLE[8] as i32;
for i in 0..7 {
cost[0] += (partial[0][i] * partial[0][i] + partial[0][14 - i] * partial[0][14 - i]) * DIV_TABLE[i + 1] as i32;
cost[4] += (partial[4][i] * partial[4][i] + partial[4][14 - i] * partial[4][14 - i]) * DIV_TABLE[i + 1] as i32;
}
cost[0] += partial[0][7] * partial[0][7] * DIV_TABLE[8] as i32;
cost[4] += partial[4][7] * partial[4][7] * DIV_TABLE[8] as i32;
let mut i = 1;
while i < 8 {
for j in 0..5 {
cost[i] += partial[i][3 + j] * partial[i][3 + j];
}
cost[i] *= DIV_TABLE[8] as i32;
for j in 0..3 {
cost[i] += (partial[i][j] * partial[i][j] + partial[i][10 - j] * partial[i][10 - j]) * DIV_TABLE[2 * j + 2] as i32;
}
i += 2;
}
let mut best = 0;
let mut y_dir = 0;
for (i, &c) in cost.iter().enumerate() {
if c > best {
best = c;
y_dir = i;
}
}
(y_dir, (best - cost[(y_dir + 4) & 7]) >> 10)
}
/// Marks a sample outside the frame (CdefAvailable = 0) in the padded block.
const CDEF_NA: i32 = i32::MIN;
/// Padded block: 8x8 plus 2 samples on every side, row stride 12.
const PAD_STRIDE: usize = 12;
#[inline(always)]
fn constrain(diff: i32, threshold: i32, adj: u32) -> i32 {
let mag = (threshold - (diff.abs() >> adj)).clamp(0, diff.abs());
if diff < 0 { -mag } else { mag }
}
/// 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 }
}
/// 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 (mi_rows, mi_cols) = (fs.fh.mi_rows as isize, fs.fh.mi_cols as isize);
let mut buf = [CDEF_NA; PAD_STRIDE * PAD_STRIDE];
let mut all_avail = true;
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..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;
} else {
all_avail = false;
}
}
}
(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 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..rect.h {
// The centre sample is read even where CdefAvailable is 0 (8x8 blocks straddling an odd
// MiRows / MiCols edge).
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;
drow[j] = filter_sample(&buf, &taps, ci, srow[j] as i32);
}
}
}
/// CDEF of a block whose taps are all available, `W` samples per row. Samples (at most 12
/// bits), differences and the weighted tap sum all fit in i16, which doubles the SIMD width.
#[inline(always)]
fn filter_rows<const W: usize>(
buf: &[i32; PAD_STRIDE * PAD_STRIDE],
taps: &[(isize, i32, i32, u32); 12],
dst: &mut Plane,
(y0, x0, h): (usize, usize, usize),
) {
let mut b16 = [0i16; PAD_STRIDE * PAD_STRIDE];
for (d, &s) in b16.iter_mut().zip(buf.iter()) {
*d = s as i16;
}
for i in 0..h {
let ci = ((i + 2) * PAD_STRIDE + 2) as isize;
let Some(&x) = b16.get(ci as usize..).and_then(<[_]>::first_chunk::<W>) else {
return;
};
let (mut sum, mut max, mut min) = ([0i16; W], x, x);
for &(off, weight, thr, adj) in taps {
let (weight, thr) = (weight as i16, thr as i16);
let start = (ci + off) as usize;
let Some(p) = b16.get(start..).and_then(<[_]>::first_chunk::<W>) else {
return;
};
for j in 0..W {
// constrain(): .max / .min instead of clamp (whose bound check panics), and
// the sign applied branch-free, so the loop vectorises.
let d = p[j] - x[j];
let ad = d.abs();
let mag = (thr - (ad >> adj)).max(0).min(ad);
let sign = d >> 15;
sum[j] += weight * ((mag ^ sign) - sign);
max[j] = max[j].max(p[j]);
min[j] = min[j].min(p[j]);
}
}
let drow = &mut dst.row_mut(y0 + i)[x0..x0 + W];
for j in 0..W {
let s = sum[j];
drow[j] = (x[j] + ((8 + s - (s < 0) as i16) >> 4)).max(min[j]).min(max[j]) as u16;
}
}
}
-379
View File
@@ -1,379 +0,0 @@
//! The adaptive CDF arrays (spec 6.8.2 init_non_coeff_cdfs / init_coeff_cdfs, load_cdfs).
use crate::spec_tables::{
DEFAULT_ANGLE_DELTA_CDF, DEFAULT_CFL_ALPHA_CDF, DEFAULT_CFL_SIGN_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF,
DEFAULT_COMP_BWD_REF_CDF, DEFAULT_COMP_GROUP_IDX_CDF, DEFAULT_COMP_MODE_CDF, DEFAULT_COMP_REF_CDF, DEFAULT_COMP_REF_TYPE_CDF,
DEFAULT_COMPOUND_IDX_CDF, DEFAULT_COMPOUND_MODE_CDF, DEFAULT_COMPOUND_TYPE_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DELTA_LF_CDF, DEFAULT_DELTA_Q_CDF,
DEFAULT_DRL_MODE_CDF, DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF,
DEFAULT_EOB_PT_256_CDF, DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_FILTER_INTRA_CDF, DEFAULT_FILTER_INTRA_MODE_CDF,
DEFAULT_INTER_INTRA_CDF, DEFAULT_INTER_INTRA_MODE_CDF, DEFAULT_INTER_TX_TYPE_SET1_CDF, DEFAULT_INTER_TX_TYPE_SET2_CDF,
DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTERP_FILTER_CDF, DEFAULT_INTRA_TX_TYPE_SET1_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_INTRABC_CDF,
DEFAULT_IS_INTER_CDF, DEFAULT_MOTION_MODE_CDF, DEFAULT_MV_BIT_CDF, DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF,
DEFAULT_MV_CLASS0_HP_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_HP_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF, DEFAULT_NEW_MV_CDF,
DEFAULT_PALETTE_SIZE_2_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_2_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_Y_COLOR_CDF,
DEFAULT_PALETTE_SIZE_4_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_4_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_Y_COLOR_CDF,
DEFAULT_PALETTE_SIZE_6_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_6_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_Y_COLOR_CDF,
DEFAULT_PALETTE_SIZE_8_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_8_Y_COLOR_CDF, DEFAULT_PALETTE_UV_MODE_CDF, DEFAULT_PALETTE_UV_SIZE_CDF,
DEFAULT_PALETTE_Y_MODE_CDF, DEFAULT_PALETTE_Y_SIZE_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF,
DEFAULT_PARTITION_W64_CDF, DEFAULT_PARTITION_W128_CDF, DEFAULT_REF_MV_CDF, DEFAULT_RESTORATION_TYPE_CDF, DEFAULT_SEGMENT_ID_CDF,
DEFAULT_SEGMENT_ID_PREDICTED_CDF, DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_SKIP_MODE_CDF, DEFAULT_TX_8X8_CDF, DEFAULT_TX_16X16_CDF,
DEFAULT_TX_32X32_CDF, DEFAULT_TX_64X64_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_TXFM_SPLIT_CDF, DEFAULT_UNI_COMP_REF_CDF, DEFAULT_USE_OBMC_CDF,
DEFAULT_USE_SGRPROJ_CDF, DEFAULT_USE_WIENER_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_WEDGE_INDEX_CDF,
DEFAULT_WEDGE_INTER_INTRA_CDF, DEFAULT_Y_MODE_CDF, DEFAULT_ZERO_MV_CDF, FRAME_LF_COUNT, MV_CONTEXTS,
};
/// Every CDF array listed by init_non_coeff_cdfs and init_coeff_cdfs. Each innermost array holds
/// N cumulative values (the last is 32768) plus the adaptation counter.
#[derive(Clone)]
pub(crate) struct Cdfs {
pub y_mode: [[u16; 14]; 4],
pub uv_mode_cfl_not_allowed: [[u16; 14]; 13],
pub uv_mode_cfl_allowed: [[u16; 15]; 13],
pub angle_delta: [[u16; 8]; 8],
pub intrabc: [u16; 3],
pub partition_w8: [[u16; 5]; 4],
pub partition_w16: [[u16; 11]; 4],
pub partition_w32: [[u16; 11]; 4],
pub partition_w64: [[u16; 11]; 4],
pub partition_w128: [[u16; 9]; 4],
pub segment_id: [[u16; 9]; 3],
pub segment_id_predicted: [[u16; 3]; 3],
pub tx_8x8: [[u16; 3]; 3],
pub tx_16x16: [[u16; 4]; 3],
pub tx_32x32: [[u16; 4]; 3],
pub tx_64x64: [[u16; 4]; 3],
pub txfm_split: [[u16; 3]; 21],
pub filter_intra_mode: [u16; 6],
pub filter_intra: [[u16; 3]; 22],
pub interp_filter: [[u16; 4]; 16],
pub motion_mode: [[u16; 4]; 22],
pub new_mv: [[u16; 3]; 6],
pub zero_mv: [[u16; 3]; 2],
pub ref_mv: [[u16; 3]; 6],
pub compound_mode: [[u16; 9]; 8],
pub drl_mode: [[u16; 3]; 3],
pub is_inter: [[u16; 3]; 4],
pub comp_mode: [[u16; 3]; 5],
pub skip_mode: [[u16; 3]; 3],
pub skip: [[u16; 3]; 3],
pub comp_ref: [[[u16; 3]; 3]; 3],
pub comp_bwd_ref: [[[u16; 3]; 2]; 3],
pub single_ref: [[[u16; 3]; 6]; 3],
pub mv_joint: [[u16; 5]; MV_CONTEXTS],
pub mv_class: [[[u16; 12]; 2]; MV_CONTEXTS],
pub mv_class0_bit: [[[u16; 3]; 2]; MV_CONTEXTS],
pub mv_fr: [[[u16; 5]; 2]; MV_CONTEXTS],
pub mv_class0_fr: [[[[u16; 5]; 2]; 2]; MV_CONTEXTS],
pub mv_class0_hp: [[[u16; 3]; 2]; MV_CONTEXTS],
pub mv_sign: [[[u16; 3]; 2]; MV_CONTEXTS],
pub mv_bit: [[[[u16; 3]; 10]; 2]; MV_CONTEXTS],
pub mv_hp: [[[u16; 3]; 2]; MV_CONTEXTS],
pub palette_y_mode: [[[u16; 3]; 3]; 7],
pub palette_uv_mode: [[u16; 3]; 2],
pub palette_y_size: [[u16; 8]; 7],
pub palette_uv_size: [[u16; 8]; 7],
pub palette_2_y_color: [[u16; 3]; 5],
pub palette_2_uv_color: [[u16; 3]; 5],
pub palette_3_y_color: [[u16; 4]; 5],
pub palette_3_uv_color: [[u16; 4]; 5],
pub palette_4_y_color: [[u16; 5]; 5],
pub palette_4_uv_color: [[u16; 5]; 5],
pub palette_5_y_color: [[u16; 6]; 5],
pub palette_5_uv_color: [[u16; 6]; 5],
pub palette_6_y_color: [[u16; 7]; 5],
pub palette_6_uv_color: [[u16; 7]; 5],
pub palette_7_y_color: [[u16; 8]; 5],
pub palette_7_uv_color: [[u16; 8]; 5],
pub palette_8_y_color: [[u16; 9]; 5],
pub palette_8_uv_color: [[u16; 9]; 5],
pub delta_q: [u16; 5],
pub delta_lf: [u16; 5],
pub delta_lf_multi: [[u16; 5]; FRAME_LF_COUNT],
pub intra_tx_type_set1: [[[u16; 8]; 13]; 2],
pub intra_tx_type_set2: [[[u16; 6]; 13]; 3],
pub inter_tx_type_set1: [[u16; 17]; 2],
pub inter_tx_type_set2: [u16; 13],
pub inter_tx_type_set3: [[u16; 3]; 4],
pub use_obmc: [[u16; 3]; 22],
pub inter_intra: [[u16; 3]; 3],
pub comp_ref_type: [[u16; 3]; 5],
pub cfl_sign: [u16; 9],
pub uni_comp_ref: [[[u16; 3]; 3]; 3],
pub wedge_inter_intra: [[u16; 3]; 22],
pub comp_group_idx: [[u16; 3]; 6],
pub compound_idx: [[u16; 3]; 6],
pub compound_type: [[u16; 3]; 22],
pub inter_intra_mode: [[u16; 5]; 3],
pub wedge_index: [[u16; 17]; 22],
pub cfl_alpha: [[u16; 17]; 6],
pub use_wiener: [u16; 3],
pub use_sgrproj: [u16; 3],
pub restoration_type: [u16; 4],
// coefficient CDFs
pub txb_skip: [[[u16; 3]; 13]; 5],
pub eob_pt_16: [[[u16; 6]; 2]; 2],
pub eob_pt_32: [[[u16; 7]; 2]; 2],
pub eob_pt_64: [[[u16; 8]; 2]; 2],
pub eob_pt_128: [[[u16; 9]; 2]; 2],
pub eob_pt_256: [[[u16; 10]; 2]; 2],
pub eob_pt_512: [[u16; 11]; 2],
pub eob_pt_1024: [[u16; 12]; 2],
pub eob_extra: [[[[u16; 3]; 9]; 2]; 5],
pub dc_sign: [[[u16; 3]; 3]; 2],
pub coeff_base_eob: [[[[u16; 4]; 4]; 2]; 5],
pub coeff_base: [[[[u16; 5]; 42]; 2]; 5],
pub coeff_br: [[[[u16; 5]; 21]; 2]; 5],
}
impl Cdfs {
/// init_non_coeff_cdfs( ) followed by init_coeff_cdfs( ) for `base_q_idx`.
pub fn new(base_q_idx: u32) -> Box<Cdfs> {
let mut c = Box::new(Cdfs {
y_mode: DEFAULT_Y_MODE_CDF,
uv_mode_cfl_not_allowed: DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF,
uv_mode_cfl_allowed: DEFAULT_UV_MODE_CFL_ALLOWED_CDF,
angle_delta: DEFAULT_ANGLE_DELTA_CDF,
intrabc: DEFAULT_INTRABC_CDF,
partition_w8: DEFAULT_PARTITION_W8_CDF,
partition_w16: DEFAULT_PARTITION_W16_CDF,
partition_w32: DEFAULT_PARTITION_W32_CDF,
partition_w64: DEFAULT_PARTITION_W64_CDF,
partition_w128: DEFAULT_PARTITION_W128_CDF,
segment_id: DEFAULT_SEGMENT_ID_CDF,
segment_id_predicted: DEFAULT_SEGMENT_ID_PREDICTED_CDF,
tx_8x8: DEFAULT_TX_8X8_CDF,
tx_16x16: DEFAULT_TX_16X16_CDF,
tx_32x32: DEFAULT_TX_32X32_CDF,
tx_64x64: DEFAULT_TX_64X64_CDF,
txfm_split: DEFAULT_TXFM_SPLIT_CDF,
filter_intra_mode: DEFAULT_FILTER_INTRA_MODE_CDF,
filter_intra: DEFAULT_FILTER_INTRA_CDF,
interp_filter: DEFAULT_INTERP_FILTER_CDF,
motion_mode: DEFAULT_MOTION_MODE_CDF,
new_mv: DEFAULT_NEW_MV_CDF,
zero_mv: DEFAULT_ZERO_MV_CDF,
ref_mv: DEFAULT_REF_MV_CDF,
compound_mode: DEFAULT_COMPOUND_MODE_CDF,
drl_mode: DEFAULT_DRL_MODE_CDF,
is_inter: DEFAULT_IS_INTER_CDF,
comp_mode: DEFAULT_COMP_MODE_CDF,
skip_mode: DEFAULT_SKIP_MODE_CDF,
skip: DEFAULT_SKIP_CDF,
comp_ref: DEFAULT_COMP_REF_CDF,
comp_bwd_ref: DEFAULT_COMP_BWD_REF_CDF,
single_ref: DEFAULT_SINGLE_REF_CDF,
mv_joint: [DEFAULT_MV_JOINT_CDF; MV_CONTEXTS],
mv_class: [DEFAULT_MV_CLASS_CDF; MV_CONTEXTS],
mv_class0_bit: [[DEFAULT_MV_CLASS0_BIT_CDF; 2]; MV_CONTEXTS],
mv_fr: [DEFAULT_MV_FR_CDF; MV_CONTEXTS],
mv_class0_fr: [DEFAULT_MV_CLASS0_FR_CDF; MV_CONTEXTS],
mv_class0_hp: [[DEFAULT_MV_CLASS0_HP_CDF; 2]; MV_CONTEXTS],
mv_sign: [[DEFAULT_MV_SIGN_CDF; 2]; MV_CONTEXTS],
mv_bit: [[DEFAULT_MV_BIT_CDF; 2]; MV_CONTEXTS],
mv_hp: [[DEFAULT_MV_HP_CDF; 2]; MV_CONTEXTS],
palette_y_mode: DEFAULT_PALETTE_Y_MODE_CDF,
palette_uv_mode: DEFAULT_PALETTE_UV_MODE_CDF,
palette_y_size: DEFAULT_PALETTE_Y_SIZE_CDF,
palette_uv_size: DEFAULT_PALETTE_UV_SIZE_CDF,
palette_2_y_color: DEFAULT_PALETTE_SIZE_2_Y_COLOR_CDF,
palette_2_uv_color: DEFAULT_PALETTE_SIZE_2_UV_COLOR_CDF,
palette_3_y_color: DEFAULT_PALETTE_SIZE_3_Y_COLOR_CDF,
palette_3_uv_color: DEFAULT_PALETTE_SIZE_3_UV_COLOR_CDF,
palette_4_y_color: DEFAULT_PALETTE_SIZE_4_Y_COLOR_CDF,
palette_4_uv_color: DEFAULT_PALETTE_SIZE_4_UV_COLOR_CDF,
palette_5_y_color: DEFAULT_PALETTE_SIZE_5_Y_COLOR_CDF,
palette_5_uv_color: DEFAULT_PALETTE_SIZE_5_UV_COLOR_CDF,
palette_6_y_color: DEFAULT_PALETTE_SIZE_6_Y_COLOR_CDF,
palette_6_uv_color: DEFAULT_PALETTE_SIZE_6_UV_COLOR_CDF,
palette_7_y_color: DEFAULT_PALETTE_SIZE_7_Y_COLOR_CDF,
palette_7_uv_color: DEFAULT_PALETTE_SIZE_7_UV_COLOR_CDF,
palette_8_y_color: DEFAULT_PALETTE_SIZE_8_Y_COLOR_CDF,
palette_8_uv_color: DEFAULT_PALETTE_SIZE_8_UV_COLOR_CDF,
delta_q: DEFAULT_DELTA_Q_CDF,
delta_lf: DEFAULT_DELTA_LF_CDF,
delta_lf_multi: [DEFAULT_DELTA_LF_CDF; FRAME_LF_COUNT],
intra_tx_type_set1: DEFAULT_INTRA_TX_TYPE_SET1_CDF,
intra_tx_type_set2: DEFAULT_INTRA_TX_TYPE_SET2_CDF,
inter_tx_type_set1: DEFAULT_INTER_TX_TYPE_SET1_CDF,
inter_tx_type_set2: DEFAULT_INTER_TX_TYPE_SET2_CDF,
inter_tx_type_set3: DEFAULT_INTER_TX_TYPE_SET3_CDF,
use_obmc: DEFAULT_USE_OBMC_CDF,
inter_intra: DEFAULT_INTER_INTRA_CDF,
comp_ref_type: DEFAULT_COMP_REF_TYPE_CDF,
cfl_sign: DEFAULT_CFL_SIGN_CDF,
uni_comp_ref: DEFAULT_UNI_COMP_REF_CDF,
wedge_inter_intra: DEFAULT_WEDGE_INTER_INTRA_CDF,
comp_group_idx: DEFAULT_COMP_GROUP_IDX_CDF,
compound_idx: DEFAULT_COMPOUND_IDX_CDF,
compound_type: DEFAULT_COMPOUND_TYPE_CDF,
inter_intra_mode: DEFAULT_INTER_INTRA_MODE_CDF,
wedge_index: DEFAULT_WEDGE_INDEX_CDF,
cfl_alpha: DEFAULT_CFL_ALPHA_CDF,
use_wiener: DEFAULT_USE_WIENER_CDF,
use_sgrproj: DEFAULT_USE_SGRPROJ_CDF,
restoration_type: DEFAULT_RESTORATION_TYPE_CDF,
txb_skip: DEFAULT_TXB_SKIP_CDF[0],
eob_pt_16: DEFAULT_EOB_PT_16_CDF[0],
eob_pt_32: DEFAULT_EOB_PT_32_CDF[0],
eob_pt_64: DEFAULT_EOB_PT_64_CDF[0],
eob_pt_128: DEFAULT_EOB_PT_128_CDF[0],
eob_pt_256: DEFAULT_EOB_PT_256_CDF[0],
eob_pt_512: DEFAULT_EOB_PT_512_CDF[0],
eob_pt_1024: DEFAULT_EOB_PT_1024_CDF[0],
eob_extra: DEFAULT_EOB_EXTRA_CDF[0],
dc_sign: DEFAULT_DC_SIGN_CDF[0],
coeff_base_eob: DEFAULT_COEFF_BASE_EOB_CDF[0],
coeff_base: DEFAULT_COEFF_BASE_CDF[0],
coeff_br: DEFAULT_COEFF_BR_CDF[0],
});
c.init_coeff(base_q_idx);
c
}
/// init_coeff_cdfs( )
pub fn init_coeff(&mut self, base_q_idx: u32) {
let idx = match base_q_idx {
0..=20 => 0,
21..=60 => 1,
61..=120 => 2,
_ => 3,
};
self.txb_skip = DEFAULT_TXB_SKIP_CDF[idx];
self.eob_pt_16 = DEFAULT_EOB_PT_16_CDF[idx];
self.eob_pt_32 = DEFAULT_EOB_PT_32_CDF[idx];
self.eob_pt_64 = DEFAULT_EOB_PT_64_CDF[idx];
self.eob_pt_128 = DEFAULT_EOB_PT_128_CDF[idx];
self.eob_pt_256 = DEFAULT_EOB_PT_256_CDF[idx];
self.eob_pt_512 = DEFAULT_EOB_PT_512_CDF[idx];
self.eob_pt_1024 = DEFAULT_EOB_PT_1024_CDF[idx];
self.eob_extra = DEFAULT_EOB_EXTRA_CDF[idx];
self.dc_sign = DEFAULT_DC_SIGN_CDF[idx];
self.coeff_base_eob = DEFAULT_COEFF_BASE_EOB_CDF[idx];
self.coeff_base = DEFAULT_COEFF_BASE_CDF[idx];
self.coeff_br = DEFAULT_COEFF_BR_CDF[idx];
}
/// The counter reset of load_cdfs( ): the last entry of every CDF array is set to 0.
pub fn clear_counts(&mut self) {
macro_rules! clr {
($($f:ident),*) => { $( self.$f.clear_count(); )* };
}
clr!(
y_mode,
uv_mode_cfl_not_allowed,
uv_mode_cfl_allowed,
angle_delta,
intrabc,
partition_w8,
partition_w16,
partition_w32,
partition_w64,
partition_w128,
segment_id,
segment_id_predicted,
tx_8x8,
tx_16x16,
tx_32x32,
tx_64x64,
txfm_split,
filter_intra_mode,
filter_intra,
interp_filter,
motion_mode,
new_mv,
zero_mv,
ref_mv,
compound_mode,
drl_mode,
is_inter,
comp_mode,
skip_mode,
skip,
comp_ref,
comp_bwd_ref,
single_ref,
mv_joint,
mv_class,
mv_class0_bit,
mv_fr,
mv_class0_fr,
mv_class0_hp,
mv_sign,
mv_bit,
mv_hp,
palette_y_mode,
palette_uv_mode,
palette_y_size,
palette_uv_size,
palette_2_y_color,
palette_2_uv_color,
palette_3_y_color,
palette_3_uv_color,
palette_4_y_color,
palette_4_uv_color,
palette_5_y_color,
palette_5_uv_color,
palette_6_y_color,
palette_6_uv_color,
palette_7_y_color,
palette_7_uv_color,
palette_8_y_color,
palette_8_uv_color,
delta_q,
delta_lf,
delta_lf_multi,
intra_tx_type_set1,
intra_tx_type_set2,
inter_tx_type_set1,
inter_tx_type_set2,
inter_tx_type_set3,
use_obmc,
inter_intra,
comp_ref_type,
cfl_sign,
uni_comp_ref,
wedge_inter_intra,
comp_group_idx,
compound_idx,
compound_type,
inter_intra_mode,
wedge_index,
cfl_alpha,
use_wiener,
use_sgrproj,
restoration_type,
txb_skip,
eob_pt_16,
eob_pt_32,
eob_pt_64,
eob_pt_128,
eob_pt_256,
eob_pt_512,
eob_pt_1024,
eob_extra,
dc_sign,
coeff_base_eob,
coeff_base,
coeff_br
);
}
}
/// Zeroes the adaptation counter (last element) of every innermost CDF array.
trait ClearCount {
fn clear_count(&mut self);
}
impl<const N: usize> ClearCount for [u16; N] {
fn clear_count(&mut self) {
self[N - 1] = 0;
}
}
impl<T: ClearCount, const M: usize> ClearCount for [T; M] {
fn clear_count(&mut self) {
for e in self.iter_mut() {
e.clear_count();
}
}
}
File diff suppressed because it is too large Load Diff
-287
View File
@@ -1,287 +0,0 @@
//! Frame sample buffers and per-4x4 mode info storage.
//!
//! Tiles decode in parallel into private buffers that cover only the tile's area: a [`Plane`]
//! or [`MiInfo`] may hold a region of the frame (origin `ox`, `oy`), addressed with frame
//! coordinates throughout.
/// Recycled plane allocations: every frame allocates (and every evicted reference frees)
/// megabytes of planes, and fresh large allocations cost page faults and kernel calls. Buffers
/// are zeroed again on reuse, so contents never leak between frames.
static PLANE_POOL: std::sync::Mutex<Vec<Vec<u16>>> = std::sync::Mutex::new(Vec::new());
const POOL_MIN_LEN: usize = 1 << 16;
/// Samples kept at most (96 M samples = 192 MB, a few 4K frames).
const POOL_MAX_SAMPLES: usize = 96 << 20;
/// A zeroed buffer of `len` samples (recycled memory for large ones).
fn zeroed(len: usize) -> Vec<u16> {
if len >= POOL_MIN_LEN {
let found = {
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
pool.iter().position(|v| v.capacity() >= len && v.capacity() <= 2 * len).map(|i| pool.swap_remove(i))
};
if let Some(mut v) = found {
v.clear();
v.resize(len, 0);
return v;
}
}
vec![0; len]
}
/// One plane of samples (u16 at every bit depth), or a rectangular region of one.
#[derive(Default)]
pub struct Plane {
pub data: Vec<u16>,
pub stride: usize,
/// Allocated rows.
pub rows: usize,
/// Frame coordinates of `data[0]` (0 for whole planes).
pub ox: usize,
pub oy: usize,
}
impl Clone for Plane {
fn clone(&self) -> Plane {
let mut data = zeroed(self.data.len());
data.copy_from_slice(&self.data);
Plane { data, stride: self.stride, rows: self.rows, ox: self.ox, oy: self.oy }
}
}
impl Drop for Plane {
fn drop(&mut self) {
if self.data.capacity() >= POOL_MIN_LEN {
let v = std::mem::take(&mut self.data);
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let kept: usize = pool.iter().map(Vec::capacity).sum();
if kept + v.capacity() <= POOL_MAX_SAMPLES {
pool.push(v);
}
}
}
}
impl Plane {
pub fn new(width: usize, height: usize) -> Plane {
Plane { data: zeroed(width * height), stride: width, rows: height, ox: 0, oy: 0 }
}
/// A `width` x `height` region with its top-left sample at frame position (ox, oy).
pub fn region(ox: usize, oy: usize, width: usize, height: usize) -> Plane {
Plane { data: zeroed(width * height), stride: width, rows: height, ox, oy }
}
#[inline(always)]
pub fn at(&self, x: usize, y: usize) -> u16 {
self.data[(y - self.oy) * self.stride + x - self.ox]
}
#[inline(always)]
pub fn set(&mut self, x: usize, y: usize, v: u16) {
self.data[(y - self.oy) * self.stride + x - self.ox] = v;
}
/// Row `y`; index 0 is column `ox` (whole planes: column 0).
#[inline(always)]
pub fn row(&self, y: usize) -> &[u16] {
let o = (y - self.oy) * self.stride;
&self.data[o..o + self.stride]
}
#[inline(always)]
pub fn row_mut(&mut self, y: usize) -> &mut [u16] {
let o = (y - self.oy) * self.stride;
&mut self.data[o..o + self.stride]
}
/// Row `y` from column `x` (frame coordinates) to the end of the stored row.
#[inline(always)]
pub fn row_from(&self, y: usize, x: usize) -> &[u16] {
let o = (y - self.oy) * self.stride;
&self.data[o + x - self.ox..o + self.stride]
}
#[inline(always)]
pub fn row_from_mut(&mut self, y: usize, x: usize) -> &mut [u16] {
let o = (y - self.oy) * self.stride;
&mut self.data[o + x - self.ox..o + self.stride]
}
/// Copy this region into the whole plane `dst` (same frame coordinates).
pub fn copy_into(&self, dst: &mut Plane) {
for y in 0..self.rows {
let s = &self.data[y * self.stride..(y + 1) * self.stride];
dst.row_from_mut(self.oy + y, self.ox)[..self.stride].copy_from_slice(s);
}
}
}
/// A frame's three planes, sized to the macroblock-aligned coded area plus margins.
#[derive(Clone, Default)]
pub struct FrameBuf {
pub planes: [Plane; 3],
pub num_planes: usize,
pub subsampling_x: usize,
pub subsampling_y: usize,
pub bit_depth: u8,
/// Frame dimensions (luma) the samples are valid for.
pub width: usize,
pub height: usize,
}
impl FrameBuf {
/// Allocate a frame for a coded area of `width` x `height` luma samples (rounded up to the
/// 128x128 superblock grid plus 160 samples of slack for blocks extending past the edge).
pub fn new(width: usize, height: usize, num_planes: usize, subsampling_x: usize, subsampling_y: usize, bit_depth: u8) -> FrameBuf {
let aw = width.div_ceil(128) * 128 + 160;
let ah = height.div_ceil(128) * 128 + 160;
let mut planes: [Plane; 3] = Default::default();
planes[0] = Plane::new(aw, ah);
for p in planes.iter_mut().take(num_planes).skip(1) {
*p = Plane::new(aw >> subsampling_x, ah >> subsampling_y);
}
FrameBuf { planes, num_planes, subsampling_x, subsampling_y, bit_depth, width, height }
}
/// The region of a frame of this geometry covering luma samples [x0, x1) x [y0, y1)
/// (chroma scaled down); the bounds are multiples of 8, or the allocated size.
pub fn region_like(&self, x0: usize, y0: usize, x1: usize, y1: usize) -> FrameBuf {
let mut planes: [Plane; 3] = Default::default();
for (p, pl) in planes.iter_mut().enumerate().take(self.num_planes) {
let (sx, sy) = if p == 0 { (0, 0) } else { (self.subsampling_x, self.subsampling_y) };
let full = &self.planes[p];
let (px0, py0) = (x0 >> sx, y0 >> sy);
let px1 = (x1 >> sx).min(full.stride);
let py1 = (y1 >> sy).min(full.rows);
*pl = Plane::region(px0, py0, px1 - px0, py1 - py0);
}
FrameBuf {
planes,
num_planes: self.num_planes,
subsampling_x: self.subsampling_x,
subsampling_y: self.subsampling_y,
bit_depth: self.bit_depth,
width: self.width,
height: self.height,
}
}
pub fn plane_width(&self, plane: usize) -> usize {
if plane == 0 { self.width } else { (self.width + self.subsampling_x) >> self.subsampling_x }
}
pub fn plane_height(&self, plane: usize) -> usize {
if plane == 0 { self.height } else { (self.height + self.subsampling_y) >> self.subsampling_y }
}
}
/// A motion vector (row, col) in 1/8 sample units.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Mv {
pub row: i16,
pub col: i16,
}
impl Mv {
pub const ZERO: Mv = Mv { row: 0, col: 0 };
pub fn new(row: i32, col: i32) -> Mv {
Mv { row: row as i16, col: col as i16 }
}
}
/// Mode info stored for every 4x4 luma position (the arrays the spec indexes by
/// `[ row ][ col ]` in mode-info units).
#[derive(Clone, Default)]
pub struct MiInfo {
/// Frame size in mode info units.
pub cols: usize,
pub rows: usize,
/// Stored region: origin and width (the whole frame unless a tile's private copy).
pub ox: usize,
pub oy: usize,
pub stride: usize,
pub region_rows: usize,
pub y_mode: Vec<u8>,
pub uv_mode: Vec<u8>,
pub ref_frame: Vec<[i8; 2]>,
pub mv: Vec<[Mv; 2]>,
pub is_inter: Vec<bool>,
pub skip_mode: Vec<bool>,
pub skip: Vec<bool>,
pub tx_size: Vec<u8>,
pub inter_tx_size: Vec<u8>,
pub mi_size: Vec<u8>,
pub segment_id: Vec<u8>,
pub palette_size: [Vec<u8>; 2],
pub palette_colors: [Vec<[u16; 8]>; 2],
pub delta_lf: Vec<[i8; 4]>,
pub comp_group_idx: Vec<u8>,
pub compound_idx: Vec<u8>,
pub interp_filter: Vec<[u8; 2]>,
pub motion_mode: Vec<u8>,
/// TxTypes[ row ][ col ] (luma 4x4 units).
pub tx_type: Vec<u8>,
/// RefFrames[ row ][ col ] has been written for this frame.
pub written: Vec<bool>,
}
impl MiInfo {
/// `palette`: whether palette mode can occur (allow_screen_content_tools); without it the
/// palette arrays (40 bytes per unit) stay empty.
pub fn new(cols: usize, rows: usize, palette: bool) -> MiInfo {
MiInfo::region(cols, rows, 0, 0, cols, rows, palette)
}
/// Storage for the w x h mode info units at (ox, oy) of a cols x rows frame.
pub fn region(cols: usize, rows: usize, ox: usize, oy: usize, w: usize, h: usize, palette: bool) -> MiInfo {
let n = w * h;
let np = if palette { n } else { 0 };
MiInfo {
cols,
rows,
ox,
oy,
stride: w,
region_rows: h,
y_mode: vec![0; n],
uv_mode: vec![0; n],
ref_frame: vec![[0, -1]; n],
mv: vec![[Mv::ZERO; 2]; n],
is_inter: vec![false; n],
skip_mode: vec![false; n],
skip: vec![false; n],
tx_size: vec![0; n],
inter_tx_size: vec![0; n],
mi_size: vec![0; n],
segment_id: vec![0; n],
palette_size: [vec![0; np], vec![0; np]],
palette_colors: [vec![[0; 8]; np], vec![[0; 8]; np]],
delta_lf: vec![[0; 4]; n],
comp_group_idx: vec![0; n],
compound_idx: vec![0; n],
interp_filter: vec![[0; 2]; n],
motion_mode: vec![0; n],
tx_type: vec![0; n],
written: vec![false; n],
}
}
#[inline(always)]
pub fn idx(&self, row: usize, col: usize) -> usize {
debug_assert!(col >= self.ox && col < self.ox + self.stride && row >= self.oy, "mode info ({row}, {col}) outside the stored region");
(row - self.oy) * self.stride + col - self.ox
}
/// Copy this region into the whole-frame `dst`.
pub fn copy_into(&self, dst: &mut MiInfo) {
let (w, h) = (self.stride, self.region_rows);
macro_rules! copy {
($($f:ident)*) => {$(
for y in 0..h {
let d = dst.idx(self.oy + y, self.ox);
dst.$f[d..d + w].copy_from_slice(&self.$f[y * w..(y + 1) * w]);
}
)*};
}
copy!(y_mode uv_mode ref_frame mv is_inter skip_mode skip tx_size inter_tx_size mi_size segment_id delta_lf comp_group_idx compound_idx interp_filter motion_mode tx_type written);
for k in (0..2).filter(|&k| !self.palette_size[k].is_empty()) {
for y in 0..h {
let d = dst.idx(self.oy + y, self.ox);
dst.palette_size[k][d..d + w].copy_from_slice(&self.palette_size[k][y * w..(y + 1) * w]);
dst.palette_colors[k][d..d + w].copy_from_slice(&self.palette_colors[k][y * w..(y + 1) * w]);
}
}
}
}
-455
View File
@@ -1,455 +0,0 @@
//! Film grain synthesis (7.18.3), applied to output pictures only (references stay clean).
use crate::Picture;
use crate::header::{FilmGrainParams, SequenceHeader};
use crate::spec_tables::{GAUSSIAN_SEQUENCE, MC_IDENTITY};
#[inline]
/// One plane's scaling lookup (7.18.3.4) from its `n` points. The header rejects points whose
/// values don't strictly increase; a pair that still doesn't (defence in depth) is skipped
/// instead of dividing by zero. No points: all zero.
fn scaling_lut(n: usize, xs: &[u8], ys: &[u8]) -> [i32; 256] {
let mut l = [0i32; 256];
let n = n.min(xs.len()).min(ys.len());
let (Some(&x0), Some(&y0)) = (xs.first(), ys.first()) else { return l };
if n == 0 {
return l;
}
for v in l.iter_mut().take(x0 as usize) {
*v = y0 as i32;
}
for i in 0..n - 1 {
let (xa, xb, ya, yb) = (xs[i] as i32, xs[i + 1] as i32, ys[i] as i32, ys[i + 1] as i32);
let dx = xb - xa;
if dx <= 0 {
continue;
}
let delta = (yb - ya) * ((65536 + (dx >> 1)) / dx);
for x in 0..dx {
if let Some(v) = l.get_mut((xa + x) as usize) {
*v = ya + ((x * delta + 32768) >> 16);
}
}
}
let (xl, yl) = (xs[n - 1], ys[n - 1]);
for v in l.iter_mut().skip(xl as usize) {
*v = yl as i32;
}
l
}
fn round2(x: i32, n: u32) -> i32 {
if n == 0 { x } else { (x + (1 << (n - 1))) >> n }
}
struct Rng(u32);
impl Rng {
/// get_random_number( bits ) (7.18.3.2)
fn next(&mut self, bits: u32) -> i32 {
let r = self.0;
let bit = (r ^ (r >> 1) ^ (r >> 3) ^ (r >> 12)) & 1;
let r = (r >> 1) | (bit << 15);
self.0 = r;
((r >> (16 - bits)) & ((1 << bits) - 1)) as i32
}
}
/// 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,
}
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() {
let gv = if g.num_y_points > 0 { GAUSSIAN_SEQUENCE[rng.next(11) as usize] as i32 } else { 0 };
*v = round2(gv, shift);
}
}
let ar_shift = c.ar_shift();
let lag = c.lag();
for y in 3..73 {
for x in 3..82 - 3 {
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
}
/// 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 {
(g.num_y_points, &g.point_y_value, &g.point_y_scaling)
} else if plane == 1 {
(g.num_cb_points, &g.point_cb_value, &g.point_cb_scaling)
} else {
(g.num_cr_points, &g.point_cr_value, &g.point_cr_scaling)
};
*l = scaling_lut(n, xs, ys);
}
lut
}
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
};
gv = round2(gv, 5).clamp(c.grain_min, c.grain_max);
}
s[idx] = gv;
}
}
}
/// 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 = self.data[luma_num][plane][i * self.stripe_w + x];
if psy == 0 {
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(self.grain_min, self.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
}
}
/// 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;
for y in 0..ch {
for x in 0..cw {
let lx = x << sub_x;
let ly = y << sub_y;
let lnx = (lx + 1).min(w - 1);
let avg = if sub_x != 0 { round2(py[ly * w + lx] as i32 + py[ly * w + lnx] as i32, 1) } else { py[ly * w + lx] as i32 };
for (plane, out, on, luma_mult, mult, offset) in [
(1usize, &mut *pu, g.num_cb_points > 0, g.cb_luma_mult, g.cb_mult, g.cb_offset),
(2, &mut *pv, g.num_cr_points > 0, g.cr_luma_mult, g.cr_mult, g.cr_offset),
] {
if !(on || g.chroma_scaling_from_luma) {
continue;
}
let orig = out[y * cw + x] as i32;
let merged = if g.chroma_scaling_from_luma {
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, self.range.pix_max)
};
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;
}
}
}
}
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_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::*;
#[test]
fn scaling_lut_interpolates_and_tolerates_duplicate_points() {
let l = scaling_lut(2, &[0, 255], &[0, 255]);
assert_eq!((l[0], l[128], l[255]), (0, 128, 255));
// Duplicate and descending x values don't divide by zero.
let l = scaling_lut(4, &[10, 10, 5, 200], &[20, 40, 60, 80]);
assert_eq!(l[0], 20);
assert_eq!(l[255], 80);
assert_eq!(scaling_lut(0, &[], &[]), [0; 256]);
}
}
File diff suppressed because it is too large Load Diff
-839
View File
@@ -1,839 +0,0 @@
//! Inter prediction (spec 7.11.3): motion vector scaling, block inter prediction, warped
//! motion, masks (wedge, difference weight, inter-intra), distance weights and OBMC.
use std::sync::OnceLock;
use crate::frame::Plane;
use crate::mvpred::round2_signed64;
use crate::spec_tables::{
BLOCK_8X8, BLOCK_SIZES, COMPOUND_AVERAGE, COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_WEDGE, DIV_LUT, DIV_LUT_BITS,
DIV_LUT_PREC_BITS, EIGHTTAP, EIGHTTAP_SHARP, EIGHTTAP_SMOOTH, FILTER_BITS, GLOBAL_GLOBALMV, GLOBALMV, II_H_PRED, II_SMOOTH_PRED, II_V_PRED,
II_WEIGHTS_1D, INTRA_FRAME, LAST_FRAME, LOCALWARP, LS_MV_MAX, MASK_MASTER_SIZE, MAX_FRAME_DISTANCE, MAX_SB_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2,
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, OBMC_MASK_2, OBMC_MASK_4, OBMC_MASK_8, OBMC_MASK_16, OBMC_MASK_32, QUANT_DIST_LOOKUP,
QUANT_DIST_WEIGHT, REF_SCALE_SHIFT, SCALE_SUBPEL_BITS, SUBPEL_BITS, SUBPEL_FILTERS, SUBPEL_MASK, TRANSLATION, WARP_PARAM_REDUCE_BITS,
WARPED_FILTERS, WARPEDDIFF_PREC_BITS, WARPEDMODEL_NONDIAGAFFINE_CLAMP, WARPEDMODEL_PREC_BITS, WARPEDMODEL_TRANS_CLAMP, WARPEDPIXEL_PREC_SHIFTS,
WEDGE_BITS, WEDGE_CODEBOOK, WEDGE_HORIZONTAL, WEDGE_MASTER_OBLIQUE_EVEN, WEDGE_MASTER_OBLIQUE_ODD, WEDGE_MASTER_VERTICAL, WEDGE_OBLIQUE27,
WEDGE_OBLIQUE63, WEDGE_OBLIQUE117, WEDGE_OBLIQUE153, WEDGE_TYPES, WEDGE_VERTICAL,
};
use crate::tile::TileDecoder;
const PRED_STRIDE: usize = 128;
#[inline(always)]
fn round2(x: i32, n: u32) -> i32 {
if n == 0 { x } else { (x + (1 << (n - 1))) >> n }
}
/// Rounding variables (7.11.3.2): (InterRound0, InterRound1, InterPostRound).
pub(crate) fn rounding(is_compound: bool, bd: u32) -> (u32, u32, u32) {
let mut r0 = 3;
let mut r1 = if is_compound { 7 } else { 11 };
if bd == 12 {
r0 += 2;
if !is_compound {
r1 -= 2;
}
}
(r0, r1, 2 * FILTER_BITS as u32 - (r0 + r1))
}
/// Resolve divisor process (7.11.3.7): (divShift, divFactor).
fn resolve_divisor(d: i64) -> (i32, i64) {
let a = d.unsigned_abs();
let n = 63 - a.leading_zeros() as i32;
let e = a as i64 - (1i64 << n);
let f = if n > DIV_LUT_BITS as i32 { round2_signed64(e, (n - DIV_LUT_BITS as i32) as u32) } else { e << (DIV_LUT_BITS as i32 - n) };
let shift = n + DIV_LUT_PREC_BITS as i32;
let factor = DIV_LUT[f as usize] as i64;
(shift, if d < 0 { -factor } else { factor })
}
/// Setup shear process (7.11.3.6): (warpValid, alpha, beta, gamma, delta).
pub(crate) fn setup_shear(p: &[i32; 6]) -> (bool, i32, i32, i32, i32) {
let prec = 1i64 << WARPEDMODEL_PREC_BITS;
let alpha0 = (p[2] as i64 - prec).clamp(-32768, 32767);
let beta0 = (p[3] as i64).clamp(-32768, 32767);
let (div_shift, div_factor) = resolve_divisor(p[2] as i64);
let v = (p[4] as i64) << WARPEDMODEL_PREC_BITS;
let gamma0 = round2_signed_wide(v as i128 * div_factor as i128, div_shift as u32).clamp(-32768, 32767);
let w = p[3] as i64 * p[4] as i64;
let delta0 = (p[5] as i64 - round2_signed_wide(w as i128 * div_factor as i128, div_shift as u32) - prec).clamp(-32768, 32767);
let rb = WARP_PARAM_REDUCE_BITS as u32;
let red = |x: i64| (round2_signed64(x, rb) << rb) as i32;
let (alpha, beta, gamma, delta) = (red(alpha0), red(beta0), red(gamma0), red(delta0));
let valid =
!(4 * alpha.abs() + 7 * beta.abs() >= (1 << WARPEDMODEL_PREC_BITS) || 4 * gamma.abs() + 4 * delta.abs() >= (1 << WARPEDMODEL_PREC_BITS));
(valid, alpha, beta, gamma, delta)
}
fn round2_signed_wide(x: i128, n: u32) -> i64 {
if n == 0 {
return x as i64;
}
(if x >= 0 { (x + (1 << (n - 1))) >> n } else { -((-x + (1 << (n - 1))) >> n) }) as i64
}
/// WedgeMasks[ bsize ][ flipSign ][ wedge ] (7.11.3.11), stored as w*h bytes each.
fn wedge_masks() -> &'static Vec<Vec<[Vec<u8>; 2]>> {
static M: OnceLock<Vec<Vec<[Vec<u8>; 2]>>> = OnceLock::new();
M.get_or_init(|| {
let n = MASK_MASTER_SIZE;
let mut master = vec![[[0u8; 64]; 64]; 6];
for j in 0..n {
let mut shift = (n / 4) as i32;
let mut i = 0;
while i < n {
master[WEDGE_OBLIQUE63][i][j] = WEDGE_MASTER_OBLIQUE_EVEN[(j as i32 - shift).clamp(0, n as i32 - 1) as usize];
shift -= 1;
master[WEDGE_OBLIQUE63][i + 1][j] = WEDGE_MASTER_OBLIQUE_ODD[(j as i32 - shift).clamp(0, n as i32 - 1) as usize];
master[WEDGE_VERTICAL][i][j] = WEDGE_MASTER_VERTICAL[j];
master[WEDGE_VERTICAL][i + 1][j] = WEDGE_MASTER_VERTICAL[j];
i += 2;
}
}
for i in 0..n {
for j in 0..n {
let msk = master[WEDGE_OBLIQUE63][i][j];
master[WEDGE_OBLIQUE27][j][i] = msk;
master[WEDGE_OBLIQUE117][i][n - 1 - j] = 64 - msk;
master[WEDGE_OBLIQUE153][n - 1 - j][i] = 64 - msk;
master[WEDGE_HORIZONTAL][j][i] = master[WEDGE_VERTICAL][i][j];
}
}
let mut out = vec![Vec::new(); BLOCK_SIZES];
for bsize in BLOCK_8X8..BLOCK_SIZES {
if WEDGE_BITS[bsize] == 0 {
continue;
}
let w = 4 * NUM_4X4_BLOCKS_WIDE[bsize] as usize;
let h = 4 * NUM_4X4_BLOCKS_HIGH[bsize] as usize;
let w4 = NUM_4X4_BLOCKS_WIDE[bsize];
let h4 = NUM_4X4_BLOCKS_HIGH[bsize];
let shape = if h4 > w4 {
0
} else if h4 < w4 {
1
} else {
2
};
for wedge in 0..WEDGE_TYPES {
let cb = &WEDGE_CODEBOOK[shape][wedge];
let dir = cb[0] as usize;
let xoff = n / 2 - ((cb[1] as usize * w) >> 3);
let yoff = n / 2 - ((cb[2] as usize * h) >> 3);
let mut sum = 0usize;
for i in 0..w {
sum += master[dir][yoff][xoff + i] as usize;
}
for i in 1..h {
sum += master[dir][yoff + i][xoff] as usize;
}
let avg = (sum + (w + h - 1) / 2) / (w + h - 1);
let flip = (avg < 32) as usize;
let mut a = vec![0u8; w * h];
let mut b = vec![0u8; w * h];
for i in 0..h {
for j in 0..w {
let m = master[dir][yoff + i][xoff + j];
a[i * w + j] = m;
b[i * w + j] = 64 - m;
}
}
let pair = if flip == 0 { [a, b] } else { [b, a] };
out[bsize].push(pair);
}
}
out
})
}
impl TileDecoder<'_, '_> {
/// Reference plane and its (lastX, lastY) for refIdx (-1: the current frame for intra BC).
fn ref_plane_dims(&self, ref_idx: i32, plane: usize) -> (i32, i32) {
let (sx, sy) = if plane > 0 { (self.fs.ssx as i32, self.fs.ssy as i32) } else { (0, 0) };
let (uw, fh) = if ref_idx < 0 {
((self.fs.fh.mi_cols * 4) as i32, (self.fs.fh.mi_rows * 4) as i32)
} else {
let ri = &self.fs.ref_info[ref_idx as usize];
(ri.upscaled_width as i32, ri.frame_height as i32)
};
(((uw + sx) >> sx) - 1, ((fh + sy) >> sy) - 1)
}
/// Motion vector scaling process (7.11.3.3): (startX, startY, stepX, stepY).
fn mv_scaling(&self, plane: usize, ref_idx: i32, x: i32, y: i32, mv: [i32; 2]) -> (i32, i32, i32, i32) {
let fh = &self.fs.fh;
let (ref_uw, ref_h) = if ref_idx < 0 {
(fh.upscaled_width as i64, fh.frame_height as i64)
} else {
let ri = &self.fs.ref_info[ref_idx as usize];
(ri.upscaled_width as i64, ri.frame_height as i64)
};
let fw = fh.frame_width as i64;
let fhh = fh.frame_height as i64;
let rs = REF_SCALE_SHIFT as i64;
let x_scale = ((ref_uw << rs) + fw / 2) / fw;
let y_scale = ((ref_h << rs) + fhh / 2) / fhh;
let (sx, sy) = if plane > 0 { (self.fs.ssx as i32, self.fs.ssy as i32) } else { (0, 0) };
let half = 1i64 << (SUBPEL_BITS - 1);
let orig_x = ((x as i64) << SUBPEL_BITS) + ((2 * mv[1]) >> sx) as i64 + half;
let orig_y = ((y as i64) << SUBPEL_BITS) + ((2 * mv[0]) >> sy) as i64 + half;
let base_x = orig_x * x_scale - (half << rs);
let base_y = orig_y * y_scale - (half << rs);
let off = (1i64 << (SCALE_SUBPEL_BITS - SUBPEL_BITS)) / 2;
let sh = (REF_SCALE_SHIFT + SUBPEL_BITS - SCALE_SUBPEL_BITS) as u32;
let start_x = round2_signed64(base_x, sh) + off;
let start_y = round2_signed64(base_y, sh) + off;
let step_x = round2_signed64(x_scale, (REF_SCALE_SHIFT - SCALE_SUBPEL_BITS) as u32);
let step_y = round2_signed64(y_scale, (REF_SCALE_SHIFT - SCALE_SUBPEL_BITS) as u32);
(start_x as i32, start_y as i32, step_x as i32, step_y as i32)
}
fn ref_plane(&self, ref_idx: i32, plane: usize) -> &Plane {
// A missing reference (rejected earlier for valid streams) predicts from the current frame.
match usize::try_from(ref_idx).ok().and_then(|i| self.fs.refs.get(i)).and_then(|r| r.as_ref()) {
Some(r) => &r.buf.planes[plane],
None => &self.t.cur.planes[plane],
}
}
/// Block inter prediction process (7.11.3.4) into `pred` (stride PRED_STRIDE); `tmp` holds
/// the intermediate (horizontally filtered) rows.
#[allow(clippy::too_many_arguments)]
fn block_inter_prediction(
&self,
plane: usize,
ref_idx: i32,
x: i32,
y: i32,
x_step: i32,
y_step: i32,
w: usize,
h: usize,
filters: [u8; 2],
rnd: (u32, u32, u32),
pred: &mut [i32],
tmp: &mut [i32],
) {
let refp = self.ref_plane(ref_idx, plane);
let (last_x, last_y) = self.ref_plane_dims(ref_idx, plane);
let mut fh = filters[1] as usize;
if w <= 4 {
if fh == EIGHTTAP || fh == EIGHTTAP_SHARP {
fh = 4;
} else if fh == EIGHTTAP_SMOOTH {
fh = 5;
}
}
let mut fv = filters[0] as usize;
if h <= 4 {
if fv == EIGHTTAP || fv == EIGHTTAP_SHARP {
fv = 4;
} else if fv == EIGHTTAP_SMOOTH {
fv = 5;
}
}
let unit = 1 << SCALE_SUBPEL_BITS;
if x_step == unit && y_step == unit {
mc_unscaled(
refp,
last_x,
last_y,
x,
y,
w,
h,
&SUBPEL_FILTERS[fh][((x >> 6) & SUBPEL_MASK as i32) as usize],
&SUBPEL_FILTERS[fv][((y >> 6) & SUBPEL_MASK as i32) as usize],
rnd,
pred,
tmp,
);
return;
}
let inter_h = ((((h as i32 - 1) * y_step + (1 << SCALE_SUBPEL_BITS) - 1) >> SCALE_SUBPEL_BITS) + 8) as usize;
for r in 0..inter_h {
let ry = ((y >> 10) + r as i32 - 3).clamp(0, last_y) as usize;
let row = refp.row(ry);
for c in 0..w {
let p = x + x_step * c as i32;
let filt = &SUBPEL_FILTERS[fh][((p >> 6) & SUBPEL_MASK as i32) as usize];
let mut s = 0i32;
let bx = (p >> 10) - 3;
for t in 0..8 {
s += filt[t] as i32 * row[(bx + t as i32).clamp(0, last_x) as usize] as i32;
}
tmp[r * w + c] = round2(s, rnd.0);
}
}
for r in 0..h {
for c in 0..w {
let p = (y & 1023) + y_step * r as i32;
let filt = &SUBPEL_FILTERS[fv][((p >> 6) & SUBPEL_MASK as i32) as usize];
let base = (p >> 10) as usize;
let mut s = 0i32;
for t in 0..8 {
s += filt[t] as i32 * tmp[(base + t) * w + c];
}
pred[r * PRED_STRIDE + c] = round2(s, rnd.1);
}
}
}
/// Block warp process (7.11.3.5) for the 8x8 section (i8, j8).
#[allow(clippy::too_many_arguments)]
fn block_warp(
&self,
params: &[i32; 6],
plane: usize,
ref_idx: i32,
x: i32,
y: i32,
i8: usize,
j8: usize,
w: usize,
h: usize,
rnd: (u32, u32, u32),
pred: &mut [i32],
) {
let refp = self.ref_plane(ref_idx, plane);
let (last_x, last_y) = self.ref_plane_dims(ref_idx, plane);
let (sx, sy) = if plane > 0 { (self.fs.ssx as i32, self.fs.ssy as i32) } else { (0, 0) };
let src_x = (x + j8 as i32 * 8 + 4) << sx;
let src_y = (y + i8 as i32 * 8 + 4) << sy;
let dst_x = params[2] as i64 * src_x as i64 + params[3] as i64 * src_y as i64 + params[0] as i64;
let dst_y = params[4] as i64 * src_x as i64 + params[5] as i64 * src_y as i64 + params[1] as i64;
let (_, alpha, beta, gamma, delta) = setup_shear(params);
let x4 = dst_x >> sx;
let y4 = dst_y >> sy;
let ix4 = (x4 >> WARPEDMODEL_PREC_BITS) as i32;
let sx4 = (x4 & ((1 << WARPEDMODEL_PREC_BITS) - 1)) as i32;
let iy4 = (y4 >> WARPEDMODEL_PREC_BITS) as i32;
let sy4 = (y4 & ((1 << WARPEDMODEL_PREC_BITS) - 1)) as i32;
let mut inter = [[0i32; 8]; 15];
for i1 in -7..8i32 {
let row = refp.row((iy4 + i1).clamp(0, last_y) as usize);
for i2 in -4..4i32 {
let sxv = sx4 + alpha * i2 + beta * i1;
let offs = (round2(sxv, WARPEDDIFF_PREC_BITS as u32) + WARPEDPIXEL_PREC_SHIFTS as i32) as usize;
let mut s = 0i32;
for i3 in 0..8i32 {
s += WARPED_FILTERS[offs][i3 as usize] as i32 * row[(ix4 + i2 - 3 + i3).clamp(0, last_x) as usize] as i32;
}
inter[(i1 + 7) as usize][(i2 + 4) as usize] = round2(s, rnd.0);
}
}
let lim_i = 4.min(h as i32 - i8 as i32 * 8 - 4);
let lim_j = 4.min(w as i32 - j8 as i32 * 8 - 4);
for i1 in -4..lim_i {
for i2 in -4..lim_j {
let syv = sy4 + gamma * i2 + delta * i1;
let offs = (round2(syv, WARPEDDIFF_PREC_BITS as u32) + WARPEDPIXEL_PREC_SHIFTS as i32) as usize;
let mut s = 0i32;
for i3 in 0..8 {
s += WARPED_FILTERS[offs][i3] as i32 * inter[(i1 + i3 as i32 + 4) as usize][(i2 + 4) as usize];
}
pred[(i8 * 8 + (i1 + 4) as usize) * PRED_STRIDE + j8 * 8 + (i2 + 4) as usize] = round2(s, rnd.1);
}
}
}
/// Warp estimation process (7.11.3.8): sets LocalWarpParams / LocalValid.
pub(crate) fn warp_estimation(&mut self) {
let mut a = [[0i64; 2]; 2];
let mut bx = [0i64; 2];
let mut by = [0i64; 2];
let mid_y = self.b.mi_row as i64 * 4 + self.b.bh4 as i64 * 2 - 1;
let mid_x = self.b.mi_col as i64 * 4 + self.b.bw4 as i64 * 2 - 1;
let suy = mid_y * 8;
let sux = mid_x * 8;
let duy = suy + self.b.mv_i[0][0] as i64;
let dux = sux + self.b.mv_i[0][1] as i64;
let ls = |a: i64, b: i64| ((a * b) >> 2) + (a + b);
for i in 0..self.mvs.num_samples {
let c = self.mvs.cand_list[i];
let sy = c[0] as i64 - suy;
let sx = c[1] as i64 - sux;
let dy = c[2] as i64 - duy;
let dx = c[3] as i64 - dux;
if (sx - dx).abs() < LS_MV_MAX as i64 && (sy - dy).abs() < LS_MV_MAX as i64 {
a[0][0] += ls(sx, sx) + 8;
a[0][1] += ls(sx, sy) + 4;
a[1][1] += ls(sy, sy) + 8;
bx[0] += ls(sx, dx) + 8;
bx[1] += ls(sy, dx) + 4;
by[0] += ls(sx, dy) + 4;
by[1] += ls(sy, dy) + 8;
}
}
let det = a[0][0] * a[1][1] - a[0][1] * a[0][1];
self.b.local_valid = det != 0;
if det == 0 {
return;
}
let (mut div_shift, mut div_factor) = resolve_divisor(det);
div_shift -= WARPEDMODEL_PREC_BITS as i32;
if div_shift < 0 {
div_factor <<= -div_shift;
div_shift = 0;
}
let prec = 1i64 << WARPEDMODEL_PREC_BITS;
let cl = WARPEDMODEL_NONDIAGAFFINE_CLAMP as i64;
let nondiag = |v: i64| round2_signed_wide(v as i128 * div_factor as i128, div_shift as u32).clamp(-cl + 1, cl - 1);
let diag = |v: i64| round2_signed_wide(v as i128 * div_factor as i128, div_shift as u32).clamp(prec - cl + 1, prec + cl - 1);
let mut p = [0i64; 6];
p[2] = diag(a[1][1] * bx[0] - a[0][1] * bx[1]);
p[3] = nondiag(-a[0][1] * bx[0] + a[0][0] * bx[1]);
p[4] = nondiag(a[1][1] * by[0] - a[0][1] * by[1]);
p[5] = diag(-a[0][1] * by[0] + a[0][0] * by[1]);
let mvx = self.b.mv_i[0][1] as i64;
let mvy = self.b.mv_i[0][0] as i64;
let vx = mvx * (1 << (WARPEDMODEL_PREC_BITS - 3)) - (mid_x * (p[2] - prec) + mid_y * p[3]);
let vy = mvy * (1 << (WARPEDMODEL_PREC_BITS - 3)) - (mid_x * p[4] + mid_y * (p[5] - prec));
let tc = WARPEDMODEL_TRANS_CLAMP as i64;
p[0] = vx.clamp(-tc, tc - 1);
p[1] = vy.clamp(-tc, tc - 1);
for i in 0..6 {
self.b.local_warp_params[i] = p[i] as i32;
}
}
/// Inter prediction process (7.11.3.1) for a w x h region of `plane` at (x, y).
#[allow(clippy::too_many_arguments)]
pub(crate) fn predict_inter(&mut self, plane: usize, x: usize, y: usize, w: usize, h: usize, cand_row: usize, cand_col: usize) {
let bd = self.fs.bit_depth;
let mi_i = self.t.mi.idx(cand_row, cand_col);
let cand_refs = self.t.mi.ref_frame[mi_i];
let cand_mvs = self.t.mi.mv[mi_i];
let filters = self.t.mi.interp_filter[mi_i];
let is_compound = cand_refs[1] > INTRA_FRAME as i8;
let rnd = rounding(is_compound, bd);
if plane == 0 && self.b.motion_mode as usize == LOCALWARP {
self.warp_estimation();
if self.b.local_valid {
let p = self.b.local_warp_params;
self.b.local_valid = setup_shear(&p).0;
}
}
let mut preds = [std::mem::take(&mut self.pred_buf[0]), std::mem::take(&mut self.pred_buf[1])];
let mut tmp = std::mem::take(&mut self.mc_tmp);
let fh = &self.fs.fh;
for ref_list in 0..1 + is_compound as usize {
let ref_frame = cand_refs[ref_list];
let ref_idx: i32 = if self.b.use_intrabc { -1 } else { fh.ref_frame_idx[ref_frame as usize - LAST_FRAME] as i32 };
let use_warp = self.warp_mode(ref_frame, ref_idx, w, h);
let mv = [cand_mvs[ref_list].row as i32, cand_mvs[ref_list].col as i32];
if use_warp != 0 {
let params = if use_warp == 1 { self.b.local_warp_params } else { fh.gm_params[ref_frame as usize] };
for i8 in 0..=((h - 1) >> 3) {
for j8 in 0..=((w - 1) >> 3) {
self.block_warp(&params, plane, ref_idx, x as i32, y as i32, i8, j8, w, h, rnd, &mut preds[ref_list]);
}
}
} else {
let (sx, sy, stx, sty) = self.mv_scaling(plane, ref_idx, x as i32, y as i32, mv);
self.block_inter_prediction(plane, ref_idx, sx, sy, stx, sty, w, h, filters, rnd, &mut preds[ref_list], &mut tmp);
}
}
let ct = self.b.compound_type;
self.fill_blend_mask(plane, w, h, &preds, rnd.2);
let (fwd, bck) = if ct == COMPOUND_DISTANCE { self.distance_weights(cand_refs) } else { (0, 0) };
let max = (1i32 << bd) - 1;
let is_inter_intra = self.b.is_inter && self.b.ref_frame[1] == INTRA_FRAME as i8;
if !is_compound && !is_inter_intra {
let pl = &mut self.t.cur.planes[plane];
for i in 0..h {
let row = &mut pl.row_from_mut(y + i, x)[..w];
for j in 0..w {
row[j] = preds[0][i * PRED_STRIDE + j].clamp(0, max) as u16;
}
}
} else if ct == COMPOUND_AVERAGE {
let pl = &mut self.t.cur.planes[plane];
for i in 0..h {
let row = &mut pl.row_from_mut(y + i, x)[..w];
for j in 0..w {
let k = i * PRED_STRIDE + j;
row[j] = round2(preds[0][k] + preds[1][k], 1 + rnd.2).clamp(0, max) as u16;
}
}
} else if ct == COMPOUND_DISTANCE {
let pl = &mut self.t.cur.planes[plane];
for i in 0..h {
let row = &mut pl.row_from_mut(y + i, x)[..w];
for j in 0..w {
let k = i * PRED_STRIDE + j;
row[j] = round2(fwd * preds[0][k] + bck * preds[1][k], 4 + rnd.2).clamp(0, max) as u16;
}
}
} else {
self.mask_blend(&preds, plane, x, y, w, h, rnd.2, max);
}
self.pred_buf = preds;
self.mc_tmp = tmp;
if self.b.motion_mode as usize == OBMC {
self.overlapped_motion_compensation(plane, w, h);
}
}
/// Warp selection for one reference: 0 none, 1 local warp, 2 global warp.
fn warp_mode(&self, ref_frame: i8, ref_idx: i32, w: usize, h: usize) -> u8 {
let fh = &self.fs.fh;
let ym = self.b.y_mode;
let global =
(ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION;
let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0;
if w < 8 || h < 8 || fh.force_integer_mv {
0
} else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid {
1
} else if global && !self.is_scaled_ref(ref_idx) && global_valid {
2
} else {
0
}
}
/// Builds the blend mask (wedge, inter-intra or difference-weighted) for the current compound type.
fn fill_blend_mask(&mut self, plane: usize, w: usize, h: usize, preds: &[Vec<i32>; 2], round_post: u32) {
let bd = self.fs.bit_depth;
let ct = self.b.compound_type;
if ct == COMPOUND_WEDGE && plane == 0 {
let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign];
for i in 0..h {
for j in 0..w {
self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32;
}
}
} else if ct == COMPOUND_INTRA {
let size_scale = MAX_SB_SIZE / h.max(w);
let im = self.b.interintra_mode;
for i in 0..h {
for j in 0..w {
self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED {
II_WEIGHTS_1D[i * size_scale] as i32
} else if im == II_H_PRED {
II_WEIGHTS_1D[j * size_scale] as i32
} else if im == II_SMOOTH_PRED {
II_WEIGHTS_1D[i.min(j) * size_scale] as i32
} else {
32
};
}
}
} else if ct == COMPOUND_DIFFWTD && plane == 0 {
for i in 0..h {
for j in 0..w {
let k = i * PRED_STRIDE + j;
let diff = (preds[0][k] - preds[1][k]).abs();
let diff = round2(diff, (bd - 8) + round_post);
let m = (38 + diff / 16).clamp(0, 64);
self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m };
}
}
}
}
fn is_scaled_ref(&self, ref_idx: i32) -> bool {
if ref_idx < 0 {
return false;
}
let fh = &self.fs.fh;
let ri = &self.fs.ref_info[ref_idx as usize];
let xs = ((ri.upscaled_width << REF_SCALE_SHIFT) + fh.frame_width / 2) / fh.frame_width;
let ys = ((ri.frame_height << REF_SCALE_SHIFT) + fh.frame_height / 2) / fh.frame_height;
xs != 1 << REF_SCALE_SHIFT || ys != 1 << REF_SCALE_SHIFT
}
#[allow(clippy::too_many_arguments)]
fn mask_blend(&mut self, preds: &[Vec<i32>; 2], plane: usize, dx: usize, dy: usize, w: usize, h: usize, post: u32, max: i32) {
let (sx, sy) = if plane > 0 { (self.fs.ssx, self.fs.ssy) } else { (0, 0) };
let interintra = self.b.interintra;
let wedge_ii = self.b.wedge_interintra;
for yy in 0..h {
for xx in 0..w {
let mk = |r: usize, c: usize| self.mask[r * PRED_STRIDE + c];
let m = if (sx == 0 && sy == 0) || (interintra && !wedge_ii) {
mk(yy, xx)
} else if sx == 1 && sy == 0 {
round2(mk(yy, 2 * xx) + mk(yy, 2 * xx + 1), 1)
} else {
round2(mk(2 * yy, 2 * xx) + mk(2 * yy, 2 * xx + 1) + mk(2 * yy + 1, 2 * xx) + mk(2 * yy + 1, 2 * xx + 1), 2)
};
let k = yy * PRED_STRIDE + xx;
let pl = &mut self.t.cur.planes[plane];
if interintra {
let p0 = round2(preds[0][k], post).clamp(0, max);
let p1 = pl.at(dx + xx, dy + yy) as i32;
pl.set(dx + xx, dy + yy, round2(m * p1 + (64 - m) * p0, 6) as u16);
} else {
let v = round2(m * preds[0][k] + (64 - m) * preds[1][k], 6 + post).clamp(0, max);
pl.set(dx + xx, dy + yy, v as u16);
}
}
}
}
fn distance_weights(&self, refs: [i8; 2]) -> (i32, i32) {
let fh = &self.fs.fh;
let mut dist = [0i32; 2];
for (rl, d) in dist.iter_mut().enumerate() {
let h = fh.order_hints[refs[rl] as usize];
*d = crate::header::relative_dist(&self.fs.seq, h, fh.order_hint).abs().clamp(0, MAX_FRAME_DISTANCE as i32);
}
let d0 = dist[1];
let d1 = dist[0];
let order = (d0 <= d1) as usize;
if d0 == 0 || d1 == 0 {
return (QUANT_DIST_LOOKUP[3][order] as i32, QUANT_DIST_LOOKUP[3][1 - order] as i32);
}
let mut i = 0;
while i < 3 {
let c0 = QUANT_DIST_WEIGHT[i][order] as i32;
let c1 = QUANT_DIST_WEIGHT[i][1 - order] as i32;
if order == 1 {
if d0 * c0 > d1 * c1 {
break;
}
} else if d0 * c0 < d1 * c1 {
break;
}
i += 1;
}
(QUANT_DIST_LOOKUP[i][order] as i32, QUANT_DIST_LOOKUP[i][1 - order] as i32)
}
/// Overlapped motion compensation (7.11.3.9).
fn overlapped_motion_compensation(&mut self, plane: usize, w: usize, h: usize) {
let (sx, sy) = if plane > 0 { (self.fs.ssx, self.fs.ssy) } else { (0, 0) };
let (mi_rows, mi_cols) = (self.fs.fh.mi_rows as usize, self.fs.fh.mi_cols as usize);
let bd = self.fs.bit_depth;
let max = (1i32 << bd) - 1;
let mut obmc = std::mem::take(&mut self.pred_buf[0]);
let mut tmp = std::mem::take(&mut self.mc_tmp);
let get_mask = |len: usize| -> &'static [u8] {
match len {
2 => &OBMC_MASK_2,
4 => &OBMC_MASK_4,
8 => &OBMC_MASK_8,
16 => &OBMC_MASK_16,
_ => &OBMC_MASK_32,
}
};
let mut jobs: Vec<(usize, usize, usize, usize, usize, usize, usize)> = Vec::new(); // (pass, cand_row, cand_col, x4, y4, predW, predH)
if self.b.avail_u && self.fs.plane_residual_size(self.b.mi_size, plane) >= BLOCK_8X8 {
let w4 = self.b.bw4;
let mut x4 = self.b.mi_col;
let y4 = self.b.mi_row;
let mut n = 0;
let limit = 4.min(MI_WIDTH_LOG2[self.b.mi_size] as usize);
while n < limit && x4 < mi_cols.min(self.b.mi_col + w4) {
let cr = self.b.mi_row - 1;
let cc = x4 | 1;
let mi = &self.t.mi;
let ci = mi.idx(cr, cc);
let step4 = (NUM_4X4_BLOCKS_WIDE[mi.mi_size[ci] as usize] as usize).clamp(2, 16);
if mi.ref_frame[ci][0] > INTRA_FRAME as i8 {
n += 1;
let pw = w.min((step4 * 4) >> sx);
let ph = (h >> 1).min(32 >> sy);
jobs.push((0, cr, cc, x4, y4, pw, ph));
}
x4 += step4;
}
}
if self.b.avail_l {
let h4 = self.b.bh4;
let x4 = self.b.mi_col;
let mut y4 = self.b.mi_row;
let mut n = 0;
let limit = 4.min(MI_HEIGHT_LOG2[self.b.mi_size] as usize);
while n < limit && y4 < mi_rows.min(self.b.mi_row + h4) {
let cc = self.b.mi_col - 1;
let cr = y4 | 1;
let mi = &self.t.mi;
let ci = mi.idx(cr, cc);
let step4 = (NUM_4X4_BLOCKS_HIGH[mi.mi_size[ci] as usize] as usize).clamp(2, 16);
if mi.ref_frame[ci][0] > INTRA_FRAME as i8 {
n += 1;
let pw = (w >> 1).min(32 >> sx);
let ph = h.min((step4 * 4) >> sy);
jobs.push((1, cr, cc, x4, y4, pw, ph));
}
y4 += step4;
}
}
// the above pass completes before the left pass reads samples; jobs keep that order
for (pass, cr, cc, x4, y4, pw, ph) in jobs {
let ci = self.t.mi.idx(cr, cc);
let m = self.t.mi.mv[ci][0];
let mv = [m.row as i32, m.col as i32];
let ref_idx = self.fs.fh.ref_frame_idx[self.t.mi.ref_frame[ci][0] as usize - LAST_FRAME] as i32;
let px = (x4 * 4) >> sx;
let py = (y4 * 4) >> sy;
let (stx, sty, sxs, sys) = self.mv_scaling(plane, ref_idx, px as i32, py as i32, mv);
let filters = self.t.mi.interp_filter[ci];
let rnd = rounding(false, bd);
self.block_inter_prediction(plane, ref_idx, stx, sty, sxs, sys, pw, ph, filters, rnd, &mut obmc, &mut tmp);
let mask = get_mask(if pass == 0 { ph } else { pw });
let pl = &mut self.t.cur.planes[plane];
for i in 0..ph {
for j in 0..pw {
let mm = if pass == 0 { mask[i] } else { mask[j] } as i32;
let o = obmc[i * PRED_STRIDE + j].clamp(0, max);
let c = pl.at(px + j, py + i) as i32;
pl.set(px + j, py + i, round2(mm * c + (64 - mm) * o, 6) as u16);
}
}
}
self.pred_buf[0] = obmc;
self.mc_tmp = tmp;
}
}
const IDENTITY_TAPS: [i16; 8] = [0, 0, 0, 128, 0, 0, 0, 0];
/// Horizontal pass of one row for a block `W` wide: taps outer, columns inner, fixed width so
/// the row stays in registers.
#[inline(always)]
fn hpass<const W: usize>(src: &[u16], hf: &[i16; 8], out: &mut [i32], half: i32, sh: u32) {
let mut acc = [half; W];
for t in 0..8 {
let f = hf[t] as i32;
let Some(p) = src.get(t..).and_then(<[_]>::first_chunk::<W>) else {
return;
};
for j in 0..W {
acc[j] += f * p[j] as i32;
}
}
let Some(o) = out.first_chunk_mut::<W>() else {
return;
};
for j in 0..W {
o[j] = acc[j] >> sh;
}
}
/// Vertical pass of one output row from 8 intermediate rows (`rows`, stride `W`).
#[inline(always)]
fn vpass<const W: usize>(rows: &[i32], vf: &[i16; 8], out: &mut [i32], half: i32, sh: u32) {
let mut acc = [half; W];
for t in 0..8 {
let f = vf[t] as i32;
let Some(p) = rows.get(t * W..).and_then(<[_]>::first_chunk::<W>) else {
return;
};
for j in 0..W {
acc[j] += f * p[j];
}
}
let Some(o) = out.first_chunk_mut::<W>() else {
return;
};
for j in 0..W {
o[j] = acc[j] >> sh;
}
}
/// Block inter prediction without reference scaling: every column / row uses the same filter
/// phase, so the filters run over whole rows (bit-identical to the per-sample process).
#[allow(clippy::too_many_arguments)]
fn mc_unscaled(
refp: &Plane,
last_x: i32,
last_y: i32,
x: i32,
y: i32,
w: usize,
h: usize,
hf: &[i16; 8],
vf: &[i16; 8],
rnd: (u32, u32, u32),
pred: &mut [i32],
tmp: &mut [i32],
) {
let x0 = (x >> 10) - 3;
let y0 = (y >> 10) - 3;
let h_copy = *hf == IDENTITY_TAPS;
let v_copy = *vf == IDENTITY_TAPS;
// With the identity vertical phase only intermediate rows 3 .. 3 + h have a non-zero tap.
let (r_lo, r_hi) = if v_copy { (3, 3 + h) } else { (0, h + 7) };
let inside = x0 >= 0 && x0 + w as i32 + 7 <= last_x + 1;
let mut edge = [0u16; 128 + 8];
let mut acc = [0i32; 128];
let (r0, r1) = (rnd.0, rnd.1);
let (half0, half1) = (1i32 << (r0 - 1), 1i32 << (r1 - 1));
for r in r_lo..r_hi {
let ry = (y0 + r as i32).clamp(0, last_y) as usize;
let row = refp.row(ry);
let src: &[u16] = if inside {
&row[x0 as usize..x0 as usize + w + 7]
} else {
for (k, e) in edge[..w + 7].iter_mut().enumerate() {
*e = row[(x0 + k as i32).clamp(0, last_x) as usize];
}
&edge[..w + 7]
};
let out = &mut tmp[r * w..r * w + w];
if h_copy {
// Round2(128 * v, InterRound0) is exact.
for (o, &v) in out.iter_mut().zip(&src[3..3 + w]) {
*o = (v as i32) << (7 - r0);
}
} else {
match w {
2 => hpass::<2>(src, hf, out, half0, r0),
4 => hpass::<4>(src, hf, out, half0, r0),
8 => hpass::<8>(src, hf, out, half0, r0),
16 => hpass::<16>(src, hf, out, half0, r0),
32 => hpass::<32>(src, hf, out, half0, r0),
64 => hpass::<64>(src, hf, out, half0, r0),
_ => {
let acc = &mut acc[..w];
acc.fill(0);
for t in 0..8 {
let f = hf[t] as i32;
for (a, &v) in acc.iter_mut().zip(&src[t..t + w]) {
*a += f * v as i32;
}
}
for (o, &a) in out.iter_mut().zip(acc.iter()) {
*o = (a + half0) >> r0;
}
}
}
}
}
for r in 0..h {
let out = &mut pred[r * PRED_STRIDE..r * PRED_STRIDE + w];
if v_copy {
for (o, &v) in out.iter_mut().zip(&tmp[(r + 3) * w..(r + 4) * w]) {
*o = (128 * v + half1) >> r1;
}
} else {
let rows = &tmp[r * w..(r + 8) * w];
match w {
2 => vpass::<2>(rows, vf, out, half1, r1),
4 => vpass::<4>(rows, vf, out, half1, r1),
8 => vpass::<8>(rows, vf, out, half1, r1),
16 => vpass::<16>(rows, vf, out, half1, r1),
32 => vpass::<32>(rows, vf, out, half1, r1),
64 => vpass::<64>(rows, vf, out, half1, r1),
_ => {
let acc = &mut acc[..w];
acc.fill(0);
for t in 0..8 {
let f = vf[t] as i32;
for (a, &v) in acc.iter_mut().zip(&tmp[(r + t) * w..(r + t + 1) * w]) {
*a += f * v;
}
}
for (o, &a) in out.iter_mut().zip(acc.iter()) {
*o = (a + half1) >> r1;
}
}
}
}
}
}
-777
View File
@@ -1,777 +0,0 @@
//! Inter frame mode info syntax (5.11.7 / 5.11.18 – 5.11.33) with its CDF selection (8.3.2).
use crate::Result;
use crate::header::{EIGHTTAP, SWITCHABLE};
use crate::mvpred::MvP;
use crate::spec_tables::{
ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_32X32, BLOCK_64X64, BLOCK_128X128, BWDREF_FRAME, CLASS0_SIZE, COMP_NEWMV_CTXS, COMPOUND_AVERAGE,
COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_MODE_CTX_MAP, COMPOUND_REFERENCE, COMPOUND_WEDGE, GLOBAL_GLOBALMV, GLOBALMV,
GOLDEN_FRAME, INTRA_FRAME, INTRABC_DELAY_PIXELS, LAST_FRAME, LAST2_FRAME, LAST3_FRAME, LOCALWARP, MV_CLASS_0, MV_INTRABC_CONTEXT,
MV_JOINT_HNZVNZ, MV_JOINT_HNZVZ, MV_JOINT_HZVNZ, NEAR_NEARMV, NEAR_NEWMV, NEAREST_NEARESTMV, NEAREST_NEWMV, NEARESTMV, NEARMV, NEW_NEARESTMV,
NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, REF_SCALE_SHIFT, SEG_LVL_GLOBALMV, SEG_LVL_REF_FRAME, SEG_LVL_SKIP,
SIMPLE, SINGLE_REFERENCE, SIZE_GROUP, TRANSLATION, UNIDIR_COMP_REFERENCE, UV_CFL_PRED, WEDGE_BITS,
};
use crate::tile::TileDecoder;
fn block_width(bs: usize) -> usize {
4 * NUM_4X4_BLOCKS_WIDE[bs] as usize
}
fn block_height(bs: usize) -> usize {
4 * NUM_4X4_BLOCKS_HIGH[bs] as usize
}
/// Neighbour reference frame summary (LeftRefFrame / AboveRefFrame and the derived flags).
#[derive(Clone, Copy, Default)]
pub(crate) struct Neighbours {
pub left: [i8; 2],
pub above: [i8; 2],
pub left_intra: bool,
pub above_intra: bool,
pub left_single: bool,
pub above_single: bool,
}
impl TileDecoder<'_, '_> {
pub(crate) fn inter_frame_mode_info_impl(&mut self) -> Result<()> {
self.b.use_intrabc = false;
let (r, c) = (self.b.mi_row, self.b.mi_col);
let mi = &self.t.mi;
let mut n = Neighbours {
left: if self.b.avail_l { mi.ref_frame[mi.idx(r, c - 1)] } else { [INTRA_FRAME as i8, -1] },
above: if self.b.avail_u { mi.ref_frame[mi.idx(r - 1, c)] } else { [INTRA_FRAME as i8, -1] },
..Default::default()
};
if !self.b.avail_l {
n.left[1] = -1;
}
if !self.b.avail_u {
n.above[1] = -1;
}
n.left_intra = n.left[0] <= INTRA_FRAME as i8;
n.above_intra = n.above[0] <= INTRA_FRAME as i8;
n.left_single = n.left[1] <= INTRA_FRAME as i8;
n.above_single = n.above[1] <= INTRA_FRAME as i8;
self.nb = n;
self.b.skip = false;
self.inter_segment_id(true);
self.read_skip_mode();
if self.b.skip_mode {
self.b.skip = true;
} else {
self.read_skip();
}
if !self.fs.fh.seg.seg_id_pre_skip {
self.inter_segment_id(false);
}
self.b.lossless = self.fs.fh.lossless_array[self.b.segment_id];
self.read_cdef();
self.read_delta_qindex();
self.read_delta_lf();
self.read_deltas = false;
self.read_is_inter();
if self.b.is_inter { self.inter_block_mode_info() } else { self.intra_block_mode_info() }
}
fn get_segment_id(&self) -> usize {
let (mi_rows, mi_cols) = (self.fs.fh.mi_rows as usize, self.fs.fh.mi_cols as usize);
let x_mis = (mi_cols - self.b.mi_col).min(self.b.bw4);
let y_mis = (mi_rows - self.b.mi_row).min(self.b.bh4);
let mut seg = 7u8;
for y in 0..y_mis {
for x in 0..x_mis {
let i = (self.b.mi_row + y) * self.t.mi.cols + self.b.mi_col + x;
seg = seg.min(self.fs.prev_segment_ids[i]);
}
}
seg as usize
}
fn set_seg_pred_context(&mut self, v: u8) {
for i in 0..self.b.bw4 {
if self.b.mi_col + i < self.t.above_seg_pred.len() {
self.t.above_seg_pred[self.b.mi_col + i] = v;
}
}
for i in 0..self.b.bh4 {
if self.b.mi_row + i < self.t.left_seg_pred.len() {
self.t.left_seg_pred[self.b.mi_row + i] = v;
}
}
}
fn inter_segment_id(&mut self, pre_skip: bool) {
let seg = &self.fs.fh.seg;
if !seg.enabled {
self.b.segment_id = 0;
return;
}
let predicted = self.get_segment_id();
if seg.update_map {
if pre_skip && !seg.seg_id_pre_skip {
self.b.segment_id = 0;
return;
}
if !pre_skip && self.b.skip {
self.set_seg_pred_context(0);
self.read_segment_id();
return;
}
if seg.temporal_update {
let ctx = self.t.left_seg_pred[self.b.mi_row] as usize + self.t.above_seg_pred[self.b.mi_col] as usize;
let pred_flag = self.sd.read_symbol(&mut self.cdf.segment_id_predicted[ctx]);
if pred_flag == 1 {
self.b.segment_id = predicted;
} else {
self.read_segment_id();
}
self.set_seg_pred_context(pred_flag as u8);
} else {
self.read_segment_id();
}
} else {
self.b.segment_id = predicted;
}
}
fn read_skip_mode(&mut self) {
let ms = self.b.mi_size;
if self.seg_active(SEG_LVL_SKIP)
|| self.seg_active(SEG_LVL_REF_FRAME)
|| self.seg_active(SEG_LVL_GLOBALMV)
|| !self.fs.fh.skip_mode_present
|| block_width(ms) < 8
|| block_height(ms) < 8
{
self.b.skip_mode = false;
} else {
let (r, c) = (self.b.mi_row, self.b.mi_col);
let mi = &self.t.mi;
let mut ctx = 0;
if self.b.avail_u {
ctx += mi.skip_mode[mi.idx(r - 1, c)] as usize;
}
if self.b.avail_l {
ctx += mi.skip_mode[mi.idx(r, c - 1)] as usize;
}
self.b.skip_mode = self.sd.read_symbol(&mut self.cdf.skip_mode[ctx]) == 1;
}
}
pub(crate) fn seg_active(&self, feature: usize) -> bool {
self.fs.fh.seg.enabled && self.fs.fh.seg.features.enabled[self.b.segment_id][feature]
}
fn read_is_inter(&mut self) {
if self.b.skip_mode {
self.b.is_inter = true;
} else if self.seg_active(SEG_LVL_REF_FRAME) {
self.b.is_inter = self.fs.fh.seg.features.data[self.b.segment_id][SEG_LVL_REF_FRAME] != INTRA_FRAME as i32;
} else if self.seg_active(SEG_LVL_GLOBALMV) {
self.b.is_inter = true;
} else {
let n = self.nb;
let ctx = if self.b.avail_u && self.b.avail_l {
if n.left_intra && n.above_intra { 3 } else { (n.left_intra || n.above_intra) as usize }
} else if self.b.avail_u || self.b.avail_l {
2 * (if self.b.avail_u { n.above_intra } else { n.left_intra }) as usize
} else {
0
};
self.b.is_inter = self.sd.read_symbol(&mut self.cdf.is_inter[ctx]) == 1;
}
}
fn intra_block_mode_info(&mut self) -> Result<()> {
self.b.ref_frame = [INTRA_FRAME as i8, -1];
let ctx = SIZE_GROUP[self.b.mi_size] as usize;
self.b.y_mode = self.sd.read_symbol(&mut self.cdf.y_mode[ctx]);
self.intra_angle_info_y();
if self.b.has_chroma {
self.read_uv_mode();
if self.b.uv_mode == UV_CFL_PRED {
self.read_cfl_alphas();
}
self.intra_angle_info_uv();
}
self.b.palette_size_y = 0;
self.b.palette_size_uv = 0;
let ms = self.b.mi_size;
if ms >= BLOCK_8X8 && block_width(ms) <= 64 && block_height(ms) <= 64 && self.fs.fh.allow_screen_content_tools {
self.palette_mode_info();
}
self.filter_intra_mode_info();
Ok(())
}
fn count_refs(&self, frame_type: i8) -> usize {
let n = self.nb;
let mut c = 0;
if self.b.avail_u {
c += (n.above[0] == frame_type) as usize + (n.above[1] == frame_type) as usize;
}
if self.b.avail_l {
c += (n.left[0] == frame_type) as usize + (n.left[1] == frame_type) as usize;
}
c
}
fn ref_count_ctx(c0: usize, c1: usize) -> usize {
match c0.cmp(&c1) {
std::cmp::Ordering::Less => 0,
std::cmp::Ordering::Equal => 1,
std::cmp::Ordering::Greater => 2,
}
}
fn ctx_comp_ref(&self) -> usize {
Self::ref_count_ctx(
self.count_refs(LAST_FRAME as i8) + self.count_refs(LAST2_FRAME as i8),
self.count_refs(LAST3_FRAME as i8) + self.count_refs(GOLDEN_FRAME as i8),
)
}
fn ctx_comp_ref_p1(&self) -> usize {
Self::ref_count_ctx(self.count_refs(LAST_FRAME as i8), self.count_refs(LAST2_FRAME as i8))
}
fn ctx_comp_ref_p2(&self) -> usize {
Self::ref_count_ctx(self.count_refs(LAST3_FRAME as i8), self.count_refs(GOLDEN_FRAME as i8))
}
fn ctx_comp_bwdref(&self) -> usize {
Self::ref_count_ctx(self.count_refs(BWDREF_FRAME as i8) + self.count_refs(ALTREF2_FRAME as i8), self.count_refs(ALTREF_FRAME as i8))
}
fn ctx_comp_bwdref_p1(&self) -> usize {
Self::ref_count_ctx(self.count_refs(BWDREF_FRAME as i8), self.count_refs(ALTREF2_FRAME as i8))
}
fn ctx_single_ref_p1(&self) -> usize {
let fwd = self.count_refs(LAST_FRAME as i8)
+ self.count_refs(LAST2_FRAME as i8)
+ self.count_refs(LAST3_FRAME as i8)
+ self.count_refs(GOLDEN_FRAME as i8);
let bwd = self.count_refs(BWDREF_FRAME as i8) + self.count_refs(ALTREF2_FRAME as i8) + self.count_refs(ALTREF_FRAME as i8);
Self::ref_count_ctx(fwd, bwd)
}
fn read_ref_frames(&mut self) {
if self.b.skip_mode {
let f = self.fs.fh.skip_mode_frame;
self.b.ref_frame = [f[0] as i8, f[1] as i8];
} else if self.seg_active(SEG_LVL_REF_FRAME) {
self.b.ref_frame = [self.fs.fh.seg.features.data[self.b.segment_id][SEG_LVL_REF_FRAME] as i8, -1];
} else if self.seg_active(SEG_LVL_SKIP) || self.seg_active(SEG_LVL_GLOBALMV) {
self.b.ref_frame = [LAST_FRAME as i8, -1];
} else {
let comp_mode = if self.fs.fh.reference_select && self.b.bw4.min(self.b.bh4) >= 2 {
let ctx = self.comp_mode_ctx();
self.sd.read_symbol(&mut self.cdf.comp_mode[ctx])
} else {
SINGLE_REFERENCE
};
if comp_mode == COMPOUND_REFERENCE {
let ctx = self.comp_ref_type_ctx();
let comp_ref_type = self.sd.read_symbol(&mut self.cdf.comp_ref_type[ctx]);
if comp_ref_type == UNIDIR_COMP_REFERENCE {
let ctx = self.ctx_single_ref_p1();
if self.sd.read_symbol(&mut self.cdf.uni_comp_ref[ctx][0]) == 1 {
self.b.ref_frame = [BWDREF_FRAME as i8, ALTREF_FRAME as i8];
} else {
let ctx1 = Self::ref_count_ctx(
self.count_refs(LAST2_FRAME as i8),
self.count_refs(LAST3_FRAME as i8) + self.count_refs(GOLDEN_FRAME as i8),
);
if self.sd.read_symbol(&mut self.cdf.uni_comp_ref[ctx1][1]) == 1 {
let ctx2 = self.ctx_comp_ref_p2();
if self.sd.read_symbol(&mut self.cdf.uni_comp_ref[ctx2][2]) == 1 {
self.b.ref_frame = [LAST_FRAME as i8, GOLDEN_FRAME as i8];
} else {
self.b.ref_frame = [LAST_FRAME as i8, LAST3_FRAME as i8];
}
} else {
self.b.ref_frame = [LAST_FRAME as i8, LAST2_FRAME as i8];
}
}
} else {
let ctx = self.ctx_comp_ref();
if self.sd.read_symbol(&mut self.cdf.comp_ref[ctx][0]) == 0 {
let ctx = self.ctx_comp_ref_p1();
let p1 = self.sd.read_symbol(&mut self.cdf.comp_ref[ctx][1]);
self.b.ref_frame[0] = if p1 == 1 { LAST2_FRAME } else { LAST_FRAME } as i8;
} else {
let ctx = self.ctx_comp_ref_p2();
let p2 = self.sd.read_symbol(&mut self.cdf.comp_ref[ctx][2]);
self.b.ref_frame[0] = if p2 == 1 { GOLDEN_FRAME } else { LAST3_FRAME } as i8;
}
let ctx = self.ctx_comp_bwdref();
if self.sd.read_symbol(&mut self.cdf.comp_bwd_ref[ctx][0]) == 0 {
let ctx = self.ctx_comp_bwdref_p1();
let p1 = self.sd.read_symbol(&mut self.cdf.comp_bwd_ref[ctx][1]);
self.b.ref_frame[1] = if p1 == 1 { ALTREF2_FRAME } else { BWDREF_FRAME } as i8;
} else {
self.b.ref_frame[1] = ALTREF_FRAME as i8;
}
}
} else {
let ctx = self.ctx_single_ref_p1();
if self.sd.read_symbol(&mut self.cdf.single_ref[ctx][0]) == 1 {
let ctx = self.ctx_comp_bwdref();
if self.sd.read_symbol(&mut self.cdf.single_ref[ctx][1]) == 0 {
let ctx = self.ctx_comp_bwdref_p1();
let p6 = self.sd.read_symbol(&mut self.cdf.single_ref[ctx][5]);
self.b.ref_frame[0] = if p6 == 1 { ALTREF2_FRAME } else { BWDREF_FRAME } as i8;
} else {
self.b.ref_frame[0] = ALTREF_FRAME as i8;
}
} else {
let ctx = self.ctx_comp_ref();
if self.sd.read_symbol(&mut self.cdf.single_ref[ctx][2]) == 1 {
let ctx = self.ctx_comp_ref_p2();
let p5 = self.sd.read_symbol(&mut self.cdf.single_ref[ctx][4]);
self.b.ref_frame[0] = if p5 == 1 { GOLDEN_FRAME } else { LAST3_FRAME } as i8;
} else {
let ctx = self.ctx_comp_ref_p1();
let p4 = self.sd.read_symbol(&mut self.cdf.single_ref[ctx][3]);
self.b.ref_frame[0] = if p4 == 1 { LAST2_FRAME } else { LAST_FRAME } as i8;
}
}
self.b.ref_frame[1] = -1;
}
}
}
fn check_backward(r: i8) -> bool {
r >= BWDREF_FRAME as i8 && r <= ALTREF_FRAME as i8
}
fn comp_mode_ctx(&self) -> usize {
let n = self.nb;
let (au, al) = (self.b.avail_u, self.b.avail_l);
if au && al {
if n.above_single && n.left_single {
(Self::check_backward(n.above[0]) ^ Self::check_backward(n.left[0])) as usize
} else if n.above_single {
2 + (Self::check_backward(n.above[0]) || n.above_intra) as usize
} else if n.left_single {
2 + (Self::check_backward(n.left[0]) || n.left_intra) as usize
} else {
4
}
} else if au {
if n.above_single { Self::check_backward(n.above[0]) as usize } else { 3 }
} else if al {
if n.left_single { Self::check_backward(n.left[0]) as usize } else { 3 }
} else {
1
}
}
fn comp_ref_type_ctx(&self) -> usize {
let n = self.nb;
let (au, al) = (self.b.avail_u, self.b.avail_l);
let samedir = |a: i8, b: i8| (a >= BWDREF_FRAME as i8) == (b >= BWDREF_FRAME as i8);
let (above0, above1, left0, left1) = (n.above[0], n.above[1], n.left[0], n.left[1]);
let above_comp_inter = au && !n.above_intra && !n.above_single;
let left_comp_inter = al && !n.left_intra && !n.left_single;
let above_uni = above_comp_inter && samedir(above0, above1);
let left_uni = left_comp_inter && samedir(left0, left1);
if au && !n.above_intra && al && !n.left_intra {
let sd = samedir(above0, left0) as usize;
if !above_comp_inter && !left_comp_inter {
1 + 2 * sd
} else if !above_comp_inter {
if !left_uni { 1 } else { 3 + sd }
} else if !left_comp_inter {
if !above_uni { 1 } else { 3 + sd }
} else if !above_uni && !left_uni {
0
} else if !above_uni || !left_uni {
2
} else {
3 + ((above0 == BWDREF_FRAME as i8) == (left0 == BWDREF_FRAME as i8)) as usize
}
} else if au && al {
if above_comp_inter {
1 + 2 * above_uni as usize
} else if left_comp_inter {
1 + 2 * left_uni as usize
} else {
2
}
} else if above_comp_inter {
4 * above_uni as usize
} else if left_comp_inter {
4 * left_uni as usize
} else {
2
}
}
fn inter_block_mode_info(&mut self) -> Result<()> {
self.b.palette_size_y = 0;
self.b.palette_size_uv = 0;
self.read_ref_frames();
let is_compound = self.b.ref_frame[1] > INTRA_FRAME as i8;
self.find_mv_stack(is_compound);
if self.b.skip_mode {
self.b.y_mode = NEAREST_NEARESTMV;
} else if self.seg_active(SEG_LVL_SKIP) || self.seg_active(SEG_LVL_GLOBALMV) {
self.b.y_mode = GLOBALMV;
} else if is_compound {
let ctx = COMPOUND_MODE_CTX_MAP[self.mvs.ref_mv_context >> 1][self.mvs.new_mv_context.min(COMP_NEWMV_CTXS - 1)] as usize;
self.b.y_mode = NEAREST_NEARESTMV + self.sd.read_symbol(&mut self.cdf.compound_mode[ctx]);
} else {
let new_mv = self.sd.read_symbol(&mut self.cdf.new_mv[self.mvs.new_mv_context]);
if new_mv == 0 {
self.b.y_mode = NEWMV;
} else {
let zero_mv = self.sd.read_symbol(&mut self.cdf.zero_mv[self.mvs.zero_mv_context]);
if zero_mv == 0 {
self.b.y_mode = GLOBALMV;
} else {
let ref_mv = self.sd.read_symbol(&mut self.cdf.ref_mv[self.mvs.ref_mv_context]);
self.b.y_mode = if ref_mv == 0 { NEARESTMV } else { NEARMV };
}
}
}
self.b.ref_mv_idx = 0;
let ym = self.b.y_mode;
if ym == NEWMV || ym == NEW_NEWMV {
for idx in 0..2 {
if self.mvs.num_mv_found > idx + 1 {
let ctx = self.mvs.drl_ctx_stack[idx];
let drl = self.sd.read_symbol(&mut self.cdf.drl_mode[ctx]);
if drl == 0 {
self.b.ref_mv_idx = idx;
break;
}
self.b.ref_mv_idx = idx + 1;
}
}
} else if matches!(ym, NEARMV | NEAR_NEARMV | NEAR_NEWMV | NEW_NEARMV) {
self.b.ref_mv_idx = 1;
for idx in 1..3 {
if self.mvs.num_mv_found > idx + 1 {
let ctx = self.mvs.drl_ctx_stack[idx];
let drl = self.sd.read_symbol(&mut self.cdf.drl_mode[ctx]);
if drl == 0 {
self.b.ref_mv_idx = idx;
break;
}
self.b.ref_mv_idx = idx + 1;
}
}
}
self.assign_mv(is_compound)?;
self.read_interintra_mode(is_compound);
self.read_motion_mode(is_compound);
self.read_compound_type(is_compound);
if self.fs.fh.interpolation_filter == SWITCHABLE {
let dual = self.fs.seq.enable_dual_filter;
for dir in 0..if dual { 2 } else { 1 } {
if self.needs_interp_filter() {
let ctx = self.interp_filter_ctx(dir);
self.b.interp_filter[dir] = self.sd.read_symbol(&mut self.cdf.interp_filter[ctx]) as u8;
} else {
self.b.interp_filter[dir] = EIGHTTAP;
}
}
if !dual {
self.b.interp_filter[1] = self.b.interp_filter[0];
}
} else {
self.b.interp_filter = [self.fs.fh.interpolation_filter; 2];
}
Ok(())
}
fn needs_interp_filter(&self) -> bool {
let ms = self.b.mi_size;
let large = block_width(ms).min(block_height(ms)) >= 8;
let gm = &self.fs.fh.gm_type;
if self.b.skip_mode || self.b.motion_mode as usize == LOCALWARP {
false
} else if large && self.b.y_mode == GLOBALMV {
gm[self.b.ref_frame[0] as usize] as usize == TRANSLATION
} else if large && self.b.y_mode == GLOBAL_GLOBALMV {
gm[self.b.ref_frame[0] as usize] as usize == TRANSLATION || gm[self.b.ref_frame[1] as usize] as usize == TRANSLATION
} else {
true
}
}
fn interp_filter_ctx(&self, dir: usize) -> usize {
let mut ctx = ((dir & 1) * 2 + (self.b.ref_frame[1] > INTRA_FRAME as i8) as usize) * 4;
let mut left_type = 3u8;
let mut above_type = 3u8;
let (r, c) = (self.b.mi_row, self.b.mi_col);
let mi = &self.t.mi;
let rf0 = self.b.ref_frame[0];
if self.b.avail_l {
let i = mi.idx(r, c - 1);
if mi.ref_frame[i][0] == rf0 || mi.ref_frame[i][1] == rf0 {
left_type = mi.interp_filter[i][dir];
}
}
if self.b.avail_u {
let i = mi.idx(r - 1, c);
if mi.ref_frame[i][0] == rf0 || mi.ref_frame[i][1] == rf0 {
above_type = mi.interp_filter[i][dir];
}
}
ctx += if left_type == above_type {
left_type as usize
} else if left_type == 3 {
above_type as usize
} else if above_type == 3 {
left_type as usize
} else {
3
};
ctx
}
fn get_mode(&self, ref_list: usize) -> usize {
let ym = self.b.y_mode;
if ref_list == 0 {
if ym < NEAREST_NEARESTMV {
ym
} else if matches!(ym, NEW_NEWMV | NEW_NEARESTMV | NEW_NEARMV) {
NEWMV
} else if matches!(ym, NEAREST_NEARESTMV | NEAREST_NEWMV) {
NEARESTMV
} else if matches!(ym, NEAR_NEARMV | NEAR_NEWMV) {
NEARMV
} else {
GLOBALMV
}
} else if matches!(ym, NEW_NEWMV | NEAREST_NEWMV | NEAR_NEWMV) {
NEWMV
} else if matches!(ym, NEAREST_NEARESTMV | NEW_NEARESTMV) {
NEARESTMV
} else if matches!(ym, NEAR_NEARMV | NEW_NEARMV) {
NEARMV
} else {
GLOBALMV
}
}
pub(crate) fn assign_mv(&mut self, is_compound: bool) -> Result<()> {
for i in 0..1 + is_compound as usize {
let comp_mode = if self.b.use_intrabc { NEWMV } else { self.get_mode(i) };
let pred: MvP = if self.b.use_intrabc {
let mut p = self.mvs.ref_stack_mv[0][0];
if p == [0, 0] {
p = self.mvs.ref_stack_mv[1][0];
}
if p == [0, 0] {
let sb_size = if self.fs.seq.use_128x128_superblock { BLOCK_128X128 } else { BLOCK_64X64 };
let sb4 = NUM_4X4_BLOCKS_HIGH[sb_size] as i32;
if (self.b.mi_row as i32) - sb4 < self.mi_row_start as i32 {
p = [0, -(sb4 * 4 + INTRABC_DELAY_PIXELS as i32) * 8];
} else {
p = [-(sb4 * 4 * 8), 0];
}
}
p
} else if comp_mode == GLOBALMV {
self.mvs.global_mvs[i]
} else {
let mut pos = if comp_mode == NEARESTMV { 0 } else { self.b.ref_mv_idx };
if comp_mode == NEWMV && self.mvs.num_mv_found <= 1 {
pos = 0;
}
self.mvs.ref_stack_mv[pos][i]
};
self.b.pred_mv[i] = pred;
if comp_mode == NEWMV {
self.read_mv(i);
} else {
self.b.mv_i[i] = pred;
}
}
Ok(())
}
fn read_mv(&mut self, r: usize) {
let mut diff = [0i32; 2];
let ctx = if self.b.use_intrabc { MV_INTRABC_CONTEXT } else { 0 };
let joint = self.sd.read_symbol(&mut self.cdf.mv_joint[ctx]);
if joint == MV_JOINT_HZVNZ || joint == MV_JOINT_HNZVNZ {
diff[0] = self.read_mv_component(ctx, 0);
}
if joint == MV_JOINT_HNZVZ || joint == MV_JOINT_HNZVNZ {
diff[1] = self.read_mv_component(ctx, 1);
}
self.b.mv_i[r] = [self.b.pred_mv[r][0] + diff[0], self.b.pred_mv[r][1] + diff[1]];
}
fn read_mv_component(&mut self, ctx: usize, comp: usize) -> i32 {
let fh = &self.fs.fh;
let (fim, hp) = (fh.force_integer_mv, fh.allow_high_precision_mv);
let sign = self.sd.read_symbol(&mut self.cdf.mv_sign[ctx][comp]);
let class = self.sd.read_symbol(&mut self.cdf.mv_class[ctx][comp]);
let mag = if class == MV_CLASS_0 {
let b0 = self.sd.read_symbol(&mut self.cdf.mv_class0_bit[ctx][comp]) as i32;
let fr = if fim { 3 } else { self.sd.read_symbol(&mut self.cdf.mv_class0_fr[ctx][comp][b0 as usize]) as i32 };
let h = if hp { self.sd.read_symbol(&mut self.cdf.mv_class0_hp[ctx][comp]) as i32 } else { 1 };
((b0 << 3) | (fr << 1) | h) + 1
} else {
let mut d = 0i32;
for i in 0..class {
d |= (self.sd.read_symbol(&mut self.cdf.mv_bit[ctx][comp][i]) as i32) << i;
}
let mut mag = (CLASS0_SIZE as i32) << (class + 2);
let fr = if fim { 3 } else { self.sd.read_symbol(&mut self.cdf.mv_fr[ctx][comp]) as i32 };
let h = if hp { self.sd.read_symbol(&mut self.cdf.mv_hp[ctx][comp]) as i32 } else { 1 };
mag += ((d << 3) | (fr << 1) | h) + 1;
mag
};
if sign == 1 { -mag } else { mag }
}
fn read_interintra_mode(&mut self, is_compound: bool) {
let ms = self.b.mi_size;
self.b.interintra = false;
if !self.b.skip_mode && self.fs.seq.enable_interintra_compound && !is_compound && (BLOCK_8X8..=BLOCK_32X32).contains(&ms) {
let ctx = SIZE_GROUP[ms] as usize - 1;
self.b.interintra = self.sd.read_symbol(&mut self.cdf.inter_intra[ctx]) == 1;
if self.b.interintra {
self.b.interintra_mode = self.sd.read_symbol(&mut self.cdf.inter_intra_mode[ctx]);
self.b.ref_frame[1] = INTRA_FRAME as i8;
self.b.angle_delta_y = 0;
self.b.angle_delta_uv = 0;
self.b.use_filter_intra = false;
self.b.wedge_interintra = self.sd.read_symbol(&mut self.cdf.wedge_inter_intra[ms]) == 1;
if self.b.wedge_interintra {
self.b.wedge_index = self.sd.read_symbol(&mut self.cdf.wedge_index[ms]);
self.b.wedge_sign = 0;
}
}
}
}
fn is_scaled(&self, rf: i8) -> bool {
let fh = &self.fs.fh;
let ri = &self.fs.ref_info[fh.ref_frame_idx[rf as usize - LAST_FRAME]];
let fw = fh.frame_width;
let fhh = fh.frame_height;
let xs = ((ri.upscaled_width << REF_SCALE_SHIFT) + fw / 2) / fw;
let ys = ((ri.frame_height << REF_SCALE_SHIFT) + fhh / 2) / fhh;
let no = 1 << REF_SCALE_SHIFT;
xs != no || ys != no
}
fn read_motion_mode(&mut self, is_compound: bool) {
self.b.motion_mode = SIMPLE as u8;
let fh = &self.fs.fh;
if self.b.skip_mode || !fh.is_motion_mode_switchable {
return;
}
let ms = self.b.mi_size;
if block_width(ms).min(block_height(ms)) < 8 {
return;
}
if !fh.force_integer_mv
&& (self.b.y_mode == GLOBALMV || self.b.y_mode == GLOBAL_GLOBALMV)
&& fh.gm_type[self.b.ref_frame[0] as usize] as usize > TRANSLATION
{
return;
}
if is_compound || self.b.ref_frame[1] == INTRA_FRAME as i8 || !self.has_overlappable_candidates() {
return;
}
self.find_warp_samples();
let fh = &self.fs.fh;
if fh.force_integer_mv || self.mvs.num_samples == 0 || !fh.allow_warped_motion || self.is_scaled(self.b.ref_frame[0]) {
let use_obmc = self.sd.read_symbol(&mut self.cdf.use_obmc[ms]);
self.b.motion_mode = if use_obmc == 1 { OBMC as u8 } else { SIMPLE as u8 };
} else {
self.b.motion_mode = self.sd.read_symbol(&mut self.cdf.motion_mode[ms]) as u8;
}
}
fn read_compound_type(&mut self, is_compound: bool) {
self.b.comp_group_idx = 0;
self.b.compound_idx = 1;
if self.b.skip_mode {
self.b.compound_type = COMPOUND_AVERAGE;
return;
}
if is_compound {
let ms = self.b.mi_size;
let n = WEDGE_BITS[ms];
if self.fs.seq.enable_masked_compound {
let ctx = self.comp_group_idx_ctx();
self.b.comp_group_idx = self.sd.read_symbol(&mut self.cdf.comp_group_idx[ctx]) as u8;
}
if self.b.comp_group_idx == 0 {
if self.fs.seq.enable_jnt_comp {
let ctx = self.compound_idx_ctx();
self.b.compound_idx = self.sd.read_symbol(&mut self.cdf.compound_idx[ctx]) as u8;
self.b.compound_type = if self.b.compound_idx == 1 { COMPOUND_AVERAGE } else { COMPOUND_DISTANCE };
} else {
self.b.compound_type = COMPOUND_AVERAGE;
}
} else if n == 0 {
self.b.compound_type = COMPOUND_DIFFWTD;
} else {
self.b.compound_type = self.sd.read_symbol(&mut self.cdf.compound_type[ms]);
}
if self.b.compound_type == COMPOUND_WEDGE {
self.b.wedge_index = self.sd.read_symbol(&mut self.cdf.wedge_index[ms]);
self.b.wedge_sign = self.sd.read_literal(1) as usize;
} else if self.b.compound_type == COMPOUND_DIFFWTD {
self.b.mask_type = self.sd.read_literal(1) as usize;
}
} else if self.b.interintra {
self.b.compound_type = if self.b.wedge_interintra { COMPOUND_WEDGE } else { COMPOUND_INTRA };
} else {
self.b.compound_type = COMPOUND_AVERAGE;
}
}
fn comp_group_idx_ctx(&self) -> usize {
let n = self.nb;
let mi = &self.t.mi;
let (r, c) = (self.b.mi_row, self.b.mi_col);
let mut ctx = 0;
if self.b.avail_u {
if !n.above_single {
ctx += mi.comp_group_idx[mi.idx(r - 1, c)] as usize;
} else if n.above[0] == ALTREF_FRAME as i8 {
ctx += 3;
}
}
if self.b.avail_l {
if !n.left_single {
ctx += mi.comp_group_idx[mi.idx(r, c - 1)] as usize;
} else if n.left[0] == ALTREF_FRAME as i8 {
ctx += 3;
}
}
ctx.min(5)
}
fn compound_idx_ctx(&self) -> usize {
let fh = &self.fs.fh;
let seq = &self.fs.seq;
let fwd = crate::header::relative_dist(seq, fh.order_hints[self.b.ref_frame[0] as usize], fh.order_hint).abs();
let bck = crate::header::relative_dist(seq, fh.order_hints[self.b.ref_frame[1] as usize], fh.order_hint).abs();
let mut ctx = if fwd == bck { 3 } else { 0 };
let n = self.nb;
let mi = &self.t.mi;
let (r, c) = (self.b.mi_row, self.b.mi_col);
if self.b.avail_u {
if !n.above_single {
ctx += mi.compound_idx[mi.idx(r - 1, c)] as usize;
} else if n.above[0] == ALTREF_FRAME as i8 {
ctx += 1;
}
}
if self.b.avail_l {
if !n.left_single {
ctx += mi.compound_idx[mi.idx(r, c - 1)] as usize;
} else if n.left[0] == ALTREF_FRAME as i8 {
ctx += 1;
}
}
ctx
}
}
-407
View File
@@ -1,407 +0,0 @@
//! Intra prediction (spec 7.11.2), palette prediction (7.11.4) and chroma from luma (7.11.5).
use crate::frame::Plane;
use crate::spec_tables::{
ANGLE_STEP, D67_PRED, DC_PRED, DR_INTRA_DERIVATIVE, INTRA_EDGE_KERNEL, INTRA_EDGE_TAPS, INTRA_FILTER_SCALE_BITS, INTRA_FILTER_TAPS,
MODE_TO_ANGLE, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SM_WEIGHTS_TX_64X64, SMOOTH_H_PRED, SMOOTH_PRED,
SMOOTH_V_PRED, V_PRED,
};
use crate::transform::round2;
/// Inputs of the intra prediction process that come from the block.
pub(crate) struct IntraParams {
pub plane: usize,
pub x: usize,
pub y: usize,
pub have_left: bool,
pub have_above: bool,
pub have_above_right: bool,
pub have_below_left: bool,
pub mode: usize,
pub log2w: u32,
pub log2h: u32,
/// Largest valid x / y in the plane: ((MiCols * MI_SIZE) >> subX) - 1.
pub max_x: i32,
pub max_y: i32,
pub bit_depth: u32,
pub angle_delta: i32,
pub use_filter_intra: bool,
pub filter_intra_mode: usize,
pub enable_intra_edge_filter: bool,
/// Result of the intra filter type process (7.11.2.8).
pub filter_type: bool,
}
const EDGE: usize = 32; // offset of index 0 in the edge buffers (they start at index -2 .. -1)
#[inline(always)]
fn clip1(v: i32, bd: u32) -> u16 {
v.clamp(0, (1 << bd) - 1) as u16
}
pub(crate) fn is_directional_mode(mode: usize) -> bool {
(V_PRED..=D67_PRED).contains(&mode)
}
/// Predict the w x h block at (x, y) of `pl` (writes into the plane).
pub(crate) fn predict_intra(pl: &mut Plane, p: &IntraParams) {
let w = 1usize << p.log2w;
let h = 1usize << p.log2h;
let bd = p.bit_depth;
let (x, y) = (p.x, p.y);
// edge arrays with room for index -2 .. 2*(w+h)
let mut above = [0i32; 2 * 128 + 2 * EDGE];
let mut left = [0i32; 2 * 128 + 2 * EDGE];
let n = w + h;
if !p.have_above && p.have_left {
let v = pl.at(x - 1, y) as i32;
above[EDGE..EDGE + n].iter_mut().for_each(|a| *a = v);
} else if !p.have_above && !p.have_left {
let v = (1 << (bd - 1)) - 1;
above[EDGE..EDGE + n].iter_mut().for_each(|a| *a = v);
} else {
let above_limit = (p.max_x).min(x as i32 + if p.have_above_right { 2 * w as i32 } else { w as i32 } - 1);
let row = pl.row_from(y - 1, x);
for i in 0..n {
above[EDGE + i] = row[(above_limit.min(x as i32 + i as i32)) as usize - x] as i32;
}
}
if !p.have_left && p.have_above {
let v = pl.at(x, y - 1) as i32;
left[EDGE..EDGE + n].iter_mut().for_each(|a| *a = v);
} else if !p.have_left && !p.have_above {
let v = (1 << (bd - 1)) + 1;
left[EDGE..EDGE + n].iter_mut().for_each(|a| *a = v);
} else {
let left_limit = (p.max_y).min(y as i32 + if p.have_below_left { 2 * h as i32 } else { h as i32 } - 1);
for i in 0..n {
left[EDGE + i] = pl.at(x - 1, left_limit.min(y as i32 + i as i32) as usize) as i32;
}
}
let corner = if p.have_above && p.have_left {
pl.at(x - 1, y - 1) as i32
} else if p.have_above {
pl.at(x, y - 1) as i32
} else if p.have_left {
pl.at(x - 1, y) as i32
} else {
1 << (bd - 1)
};
above[EDGE - 1] = corner;
left[EDGE - 1] = corner;
let mut pred = [0u16; 64 * 64];
if p.plane == 0 && p.use_filter_intra {
recursive_intra(&above, &left, w, h, p.filter_intra_mode, bd, &mut pred);
} else if is_directional_mode(p.mode) {
directional(&mut above, &mut left, p, w, h, &mut pred);
} else if p.mode == SMOOTH_PRED || p.mode == SMOOTH_V_PRED || p.mode == SMOOTH_H_PRED {
smooth(&above, &left, p.mode, p.log2w, p.log2h, w, h, &mut pred);
} else if p.mode == DC_PRED {
dc(&above, &left, p.have_left, p.have_above, p.log2w, p.log2h, w, h, bd, &mut pred);
} else {
// PAETH_PRED
let tl = above[EDGE - 1];
for i in 0..h {
for j in 0..w {
let a = above[EDGE + j];
let l = left[EDGE + i];
let base = a + l - tl;
let p_left = (base - l).abs();
let p_top = (base - a).abs();
let p_tl = (base - tl).abs();
pred[i * w + j] = if p_left <= p_top && p_left <= p_tl {
l as u16
} else if p_top <= p_tl {
a as u16
} else {
tl as u16
};
}
}
}
for i in 0..h {
pl.row_from_mut(y + i, x)[..w].copy_from_slice(&pred[i * w..i * w + w]);
}
}
fn recursive_intra(above: &[i32], left: &[i32], w: usize, h: usize, mode: usize, bd: u32, pred: &mut [u16]) {
let w4 = w >> 2;
let h2 = h >> 1;
for i2 in 0..h2 {
for j4 in 0..w4 {
let mut pv = [0i32; 7];
for (i, v) in pv.iter_mut().enumerate() {
*v = if i < 5 {
if i2 == 0 {
above[EDGE + (j4 << 2) + i - 1]
} else if j4 == 0 && i == 0 {
left[EDGE + (i2 << 1) - 1]
} else {
pred[((i2 << 1) - 1) * w + (j4 << 2) + i - 1] as i32
}
} else if j4 == 0 {
left[EDGE + (i2 << 1) + i - 5]
} else {
pred[((i2 << 1) + i - 5) * w + (j4 << 2) - 1] as i32
};
}
for i1 in 0..2 {
for j1 in 0..4 {
let mut pr = 0i32;
for i in 0..7 {
pr += INTRA_FILTER_TAPS[mode][(i1 << 2) + j1][i] as i32 * pv[i];
}
pred[((i2 << 1) + i1) * w + (j4 << 2) + j1] = clip1(round2_signed(pr, INTRA_FILTER_SCALE_BITS as u32), bd);
}
}
}
}
}
#[inline(always)]
pub(crate) fn round2_signed(x: i32, n: u32) -> i32 {
if x >= 0 { round2(x, n) } else { -round2(-x, n) }
}
fn sm_weights(log2: u32) -> &'static [u8] {
match log2 {
2 => &SM_WEIGHTS_TX_4X4,
3 => &SM_WEIGHTS_TX_8X8,
4 => &SM_WEIGHTS_TX_16X16,
5 => &SM_WEIGHTS_TX_32X32,
_ => &SM_WEIGHTS_TX_64X64,
}
}
#[allow(clippy::too_many_arguments)]
fn smooth(above: &[i32], left: &[i32], mode: usize, log2w: u32, log2h: u32, w: usize, h: usize, pred: &mut [u16]) {
let wx = sm_weights(log2w);
let wy = sm_weights(log2h);
for i in 0..h {
for j in 0..w {
let v = if mode == SMOOTH_PRED {
let s = wy[i] as i32 * above[EDGE + j]
+ (256 - wy[i] as i32) * left[EDGE + h - 1]
+ wx[j] as i32 * left[EDGE + i]
+ (256 - wx[j] as i32) * above[EDGE + w - 1];
round2(s, 9)
} else if mode == SMOOTH_V_PRED {
round2(wy[i] as i32 * above[EDGE + j] + (256 - wy[i] as i32) * left[EDGE + h - 1], 8)
} else {
round2(wx[j] as i32 * left[EDGE + i] + (256 - wx[j] as i32) * above[EDGE + w - 1], 8)
};
pred[i * w + j] = v as u16;
}
}
}
#[allow(clippy::too_many_arguments)]
fn dc(above: &[i32], left: &[i32], have_left: bool, have_above: bool, log2w: u32, log2h: u32, w: usize, h: usize, bd: u32, pred: &mut [u16]) {
let v = if have_left && have_above {
let mut sum: i32 = left[EDGE..EDGE + h].iter().sum::<i32>() + above[EDGE..EDGE + w].iter().sum::<i32>();
sum += ((w + h) >> 1) as i32;
sum / (w + h) as i32
} else if have_left {
let sum: i32 = left[EDGE..EDGE + h].iter().sum();
(sum + (h >> 1) as i32) >> log2h
} else if have_above {
let sum: i32 = above[EDGE..EDGE + w].iter().sum();
(sum + (w >> 1) as i32) >> log2w
} else {
1 << (bd - 1)
};
let v = clip1(v, bd);
pred[..w * h].iter_mut().for_each(|p| *p = v);
}
fn edge_filter_strength(w: usize, h: usize, filter_type: bool, delta: i32) -> u32 {
let d = delta.abs();
let blk_wh = w + h;
let mut strength = 0;
if !filter_type {
if blk_wh <= 8 {
if d >= 56 {
strength = 1;
}
} else if blk_wh <= 16 {
if d >= 40 {
strength = 1;
}
} else if blk_wh <= 24 {
if d >= 8 {
strength = 1;
}
if d >= 16 {
strength = 2;
}
if d >= 32 {
strength = 3;
}
} else if blk_wh <= 32 {
strength = 1;
if d >= 4 {
strength = 2;
}
if d >= 32 {
strength = 3;
}
} else {
strength = 3;
}
} else if blk_wh <= 8 {
if d >= 40 {
strength = 1;
}
if d >= 64 {
strength = 2;
}
} else if blk_wh <= 16 {
if d >= 20 {
strength = 1;
}
if d >= 48 {
strength = 2;
}
} else if blk_wh <= 24 {
if d >= 4 {
strength = 3;
}
} else {
strength = 3;
}
strength
}
fn use_upsample(w: usize, h: usize, filter_type: bool, delta: i32) -> bool {
let d = delta.abs();
let blk_wh = w + h;
if d <= 0 || d >= 40 {
false
} else if !filter_type {
blk_wh <= 16
} else {
blk_wh <= 8
}
}
/// Intra edge filter (7.11.2.12) on buf[-1 .. sz-2] (edge[i] = buf[i - 1]).
fn edge_filter(buf: &mut [i32], sz: usize, strength: u32) {
if strength == 0 {
return;
}
let mut edge = [0i32; 2 * 128 + 2 * EDGE];
for i in 0..sz {
edge[i] = buf[EDGE + i - 1];
}
for i in 1..sz {
let mut s = 0;
for j in 0..INTRA_EDGE_TAPS {
let k = (i as i32 - 2 + j as i32).clamp(0, sz as i32 - 1) as usize;
s += INTRA_EDGE_KERNEL[strength as usize - 1][j] as i32 * edge[k];
}
buf[EDGE + i - 1] = (s + 8) >> 4;
}
}
/// Intra edge upsample (7.11.2.11).
fn edge_upsample(buf: &mut [i32], num_px: usize, bd: u32) {
let mut dup = [0i32; 2 * 128 + 8];
dup[0] = buf[EDGE - 1];
for i in -1..num_px as i32 {
dup[(i + 2) as usize] = buf[(EDGE as i32 + i) as usize];
}
dup[num_px + 2] = buf[EDGE + num_px - 1];
buf[EDGE - 2] = dup[0];
for i in 0..num_px {
let s = -dup[i] + 9 * dup[i + 1] + 9 * dup[i + 2] - dup[i + 3];
let s = clip1(round2(s, 4), bd) as i32;
buf[EDGE + 2 * i - 1] = s;
buf[EDGE + 2 * i] = dup[i + 2];
}
}
fn directional(above: &mut [i32], left: &mut [i32], p: &IntraParams, w: usize, h: usize, pred: &mut [u16]) {
let p_angle = MODE_TO_ANGLE[p.mode] as i32 + p.angle_delta * ANGLE_STEP as i32;
let mut upsample_above = 0u32;
let mut upsample_left = 0u32;
if p.enable_intra_edge_filter {
if p_angle != 90 && p_angle != 180 {
if p_angle > 90 && p_angle < 180 && (w + h) >= 24 {
let s = left[EDGE] * 5 + above[EDGE - 1] * 6 + above[EDGE] * 5;
let c = round2(s, 4);
left[EDGE - 1] = c;
above[EDGE - 1] = c;
}
if p.have_above {
let strength = edge_filter_strength(w, h, p.filter_type, p_angle - 90);
let num_px = w.min((p.max_x - p.x as i32 + 1) as usize) + if p_angle < 90 { h } else { 0 } + 1;
edge_filter(above, num_px, strength);
}
if p.have_left {
let strength = edge_filter_strength(w, h, p.filter_type, p_angle - 180);
let num_px = h.min((p.max_y - p.y as i32 + 1) as usize) + if p_angle > 180 { w } else { 0 } + 1;
edge_filter(left, num_px, strength);
}
}
if use_upsample(w, h, p.filter_type, p_angle - 90) {
upsample_above = 1;
let num_px = w + if p_angle < 90 { h } else { 0 };
edge_upsample(above, num_px, p.bit_depth);
}
if use_upsample(w, h, p.filter_type, p_angle - 180) {
upsample_left = 1;
let num_px = h + if p_angle > 180 { w } else { 0 };
edge_upsample(left, num_px, p.bit_depth);
}
}
let dx = if p_angle < 90 {
DR_INTRA_DERIVATIVE[p_angle as usize] as i32
} else if p_angle > 90 && p_angle < 180 {
DR_INTRA_DERIVATIVE[(180 - p_angle) as usize] as i32
} else {
0
};
let dy = if p_angle > 90 && p_angle < 180 {
DR_INTRA_DERIVATIVE[(p_angle - 90) as usize] as i32
} else if p_angle > 180 {
DR_INTRA_DERIVATIVE[(270 - p_angle) as usize] as i32
} else {
0
};
let a = |i: i32| above[(EDGE as i32 + i) as usize];
let l = |i: i32| left[(EDGE as i32 + i) as usize];
for i in 0..h as i32 {
for j in 0..w as i32 {
let v = if p_angle < 90 {
let idx = (i + 1) * dx;
let base = (idx >> (6 - upsample_above)) + (j << upsample_above);
let shift = ((idx << upsample_above) >> 1) & 0x1f;
let max_base_x = (w as i32 + h as i32 - 1) << upsample_above;
if base < max_base_x { round2(a(base) * (32 - shift) + a(base + 1) * shift, 5) } else { a(max_base_x) }
} else if p_angle > 90 && p_angle < 180 {
let idx = (j << 6) - (i + 1) * dx;
let base = idx >> (6 - upsample_above);
if base >= -(1 << upsample_above) {
let shift = ((idx << upsample_above) >> 1) & 0x1f;
round2(a(base) * (32 - shift) + a(base + 1) * shift, 5)
} else {
let idx = (i << 6) - (j + 1) * dy;
let base = idx >> (6 - upsample_left);
let shift = ((idx << upsample_left) >> 1) & 0x1f;
round2(l(base) * (32 - shift) + l(base + 1) * shift, 5)
}
} else if p_angle > 180 {
let idx = (j + 1) * dy;
let base = (idx >> (6 - upsample_left)) + (i << upsample_left);
let shift = ((idx << upsample_left) >> 1) & 0x1f;
round2(l(base) * (32 - shift) + l(base + 1) * shift, 5)
} else if p_angle == 90 {
a(j)
} else {
l(i)
};
pred[(i as usize) * w + j as usize] = v as u16;
}
}
}
-123
View File
@@ -1,123 +0,0 @@
//! Clean-room AV1 decoder, implemented from the AV1 Bitstream & Decoding Process Specification
//! v1.0.0 with Errata 1 (AOMediaCodec/av1-spec).
//!
//! See the crate README for the conformance status of each decoding stage.
// Items used only by stages still being written (inter prediction, post filters).
// Spec-style indexed loops (FilmCraft allows this lint workspace-wide).
#![allow(clippy::needless_range_loop)]
#![allow(dead_code)]
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod bits;
mod cdef;
mod cdf;
mod decoder;
mod frame;
mod grain;
pub mod header;
mod inter;
mod inter_info;
mod intra;
mod mvpred;
mod par;
mod postfilter;
mod restoration;
mod spec_tables;
mod state;
mod stats;
mod symbol;
mod tile;
mod transform;
pub use decoder::Decoder;
pub use header::{ColorConfig, FrameHeader, SequenceHeader};
pub use stats::{DecodeStats, Stage};
/// Decoder errors.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Error {
#[error("truncated AV1 data")]
Truncated,
#[error("invalid AV1 data: {0}")]
Invalid(&'static str),
#[error("unsupported AV1 feature: {0}")]
Unsupported(&'static str),
}
pub type Result<T> = std::result::Result<T, Error>;
/// A decoded (shown) frame: planar samples without row padding, `bit_depth` significant bits in
/// `u16`. Chroma planes are `((width + ssx) >> ssx) x ((height + ssy) >> ssy)`; they are empty
/// for monochrome streams.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Picture {
pub width: u32,
pub height: u32,
pub bit_depth: u8,
pub subsampling_x: u8,
pub subsampling_y: u8,
pub mono_chrome: bool,
pub planes: [Vec<u16>; 3],
pub color_primaries: u8,
pub transfer_characteristics: u8,
pub matrix_coefficients: u8,
pub full_range: bool,
/// Timestamp of the temporal unit that showed the picture ([`Decoder::decode_pts`]).
pub pts: i64,
/// Decoded in draft mode without deblocking / CDEF / loop restoration
/// ([`Decoder::set_draft`]): approximate, for reduced-resolution playback only.
pub draft: bool,
}
impl Picture {
pub fn plane_width(&self, plane: usize) -> u32 {
if plane == 0 { self.width } else { (self.width + self.subsampling_x as u32) >> self.subsampling_x }
}
pub fn plane_height(&self, plane: usize) -> u32 {
if plane == 0 { self.height } else { (self.height + self.subsampling_y as u32) >> self.subsampling_y }
}
}
/// Whether a temporal unit starts a random access point: its first frame is a shown key frame
/// (used for keyframe detection: containers may flag samples as sync that are not).
pub fn is_key_frame_unit(data: &[u8]) -> bool {
let mut pos = 0;
let mut seq_seen = false;
while pos < data.len() {
let h = data[pos];
let obu_type = (h >> 3) & 0xf;
let ext = h & 4 != 0;
let has_size = h & 2 != 0;
let mut p = pos + 1 + ext as usize;
let size = if has_size {
match bits::leb128(data.get(p..).unwrap_or(&[])) {
Ok((v, n)) => {
p += n;
v as usize
}
Err(_) => return false,
}
} else {
data.len().saturating_sub(p)
};
if p > data.len() {
return false;
}
match obu_type {
1 => seq_seen = true,
3 | 6 => {
let b = data.get(p).copied().unwrap_or(0);
// show_existing_frame = 0, frame_type = KEY_FRAME (0), show_frame = 1
let show_existing = b >> 7;
let frame_type = (b >> 5) & 3;
let show_frame = (b >> 4) & 1;
let _ = seq_seen;
return show_existing == 0 && frame_type == 0 && show_frame == 1;
}
_ => {}
}
pos = p.saturating_add(size);
}
false
}
-737
View File
@@ -1,737 +0,0 @@
//! Motion vector prediction processes (spec 7.10): find_mv_stack, has_overlappable_candidates
//! and find_warp_samples.
use crate::spec_tables::{
GLOBAL_GLOBALMV, GLOBALMV, IDENTITY, INTRA_FRAME, LEAST_SQUARES_SAMPLES_MAX, MAX_REF_MV_STACK_SIZE, MV_BORDER, NEAR_NEWMV, NEAREST_NEWMV,
NEW_NEARESTMV, NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, REF_CAT_LEVEL, TRANSLATION, WARPEDMODEL_PREC_BITS,
};
use crate::tile::TileDecoder;
pub(crate) type MvP = [i32; 2];
pub(crate) const INVALID_MV: i32 = -1 << 15;
/// Output of find_mv_stack plus its working state.
#[derive(Clone, Default)]
pub(crate) struct MvStack {
pub ref_stack_mv: [[MvP; 2]; MAX_REF_MV_STACK_SIZE + 1],
pub weight_stack: [u32; MAX_REF_MV_STACK_SIZE + 1],
pub num_mv_found: usize,
pub new_mv_count: usize,
pub global_mvs: [MvP; 2],
pub found_match: bool,
pub close_matches: usize,
pub total_matches: usize,
pub new_mv_context: usize,
pub ref_mv_context: usize,
pub zero_mv_context: usize,
pub drl_ctx_stack: [usize; MAX_REF_MV_STACK_SIZE + 1],
ref_id_count: [usize; 2],
ref_diff_count: [usize; 2],
ref_id_mvs: [[MvP; 2]; 2],
ref_diff_mvs: [[MvP; 2]; 2],
// warp samples
pub num_samples: usize,
pub num_samples_scanned: usize,
pub cand_list: [[i32; 4]; LEAST_SQUARES_SAMPLES_MAX],
}
fn block_width(bs: usize) -> i32 {
4 * NUM_4X4_BLOCKS_WIDE[bs] as i32
}
fn block_height(bs: usize) -> i32 {
4 * NUM_4X4_BLOCKS_HIGH[bs] as i32
}
#[inline]
pub(crate) fn round2_signed64(x: i64, n: u32) -> i64 {
if n == 0 {
return x;
}
if x >= 0 { (x + (1 << (n - 1))) >> n } else { -((-x + (1 << (n - 1))) >> n) }
}
pub(crate) fn has_newmv(mode: usize) -> bool {
matches!(mode, NEWMV | NEW_NEWMV | NEAR_NEWMV | NEW_NEARMV | NEAREST_NEWMV | NEW_NEARESTMV)
}
impl TileDecoder<'_, '_> {
/// lower_mv_precision( candMv ) (7.10.2.10)
pub(crate) fn lower_mv_precision(&self, mv: &mut MvP) {
let fh = &self.fs.fh;
if fh.allow_high_precision_mv {
return;
}
for c in mv.iter_mut() {
if fh.force_integer_mv {
let a = c.abs();
let a_int = (a + 3) >> 3;
*c = if *c > 0 { a_int << 3 } else { -(a_int << 3) };
} else if *c & 1 != 0 {
*c += if *c > 0 { -1 } else { 1 };
}
}
}
/// Setup global MV process (7.10.2.1).
pub(crate) fn setup_global_mv(&self, ref_list: usize) -> MvP {
let rf = self.b.ref_frame[ref_list];
let fh = &self.fs.fh;
let mut mv: MvP = [0, 0];
if rf > INTRA_FRAME as i8 {
let rf = rf as usize;
let typ = fh.gm_type[rf] as usize;
let gm = &fh.gm_params[rf];
if typ == TRANSLATION {
mv = [gm[0] >> (WARPEDMODEL_PREC_BITS - 3), gm[1] >> (WARPEDMODEL_PREC_BITS - 3)];
} else if typ != IDENTITY {
let bw = block_width(self.b.mi_size) as i64;
let bh = block_height(self.b.mi_size) as i64;
let x = self.b.mi_col as i64 * 4 + bw / 2 - 1;
let y = self.b.mi_row as i64 * 4 + bh / 2 - 1;
let p = 1i64 << WARPEDMODEL_PREC_BITS;
let xc = (gm[2] as i64 - p) * x + gm[3] as i64 * y + gm[0] as i64;
let yc = gm[4] as i64 * x + (gm[5] as i64 - p) * y + gm[1] as i64;
if fh.allow_high_precision_mv {
mv = [round2_signed64(yc, WARPEDMODEL_PREC_BITS as u32 - 3) as i32, round2_signed64(xc, WARPEDMODEL_PREC_BITS as u32 - 3) as i32];
} else {
mv = [
round2_signed64(yc, WARPEDMODEL_PREC_BITS as u32 - 2) as i32 * 2,
round2_signed64(xc, WARPEDMODEL_PREC_BITS as u32 - 2) as i32 * 2,
];
}
}
}
self.lower_mv_precision(&mut mv);
mv
}
/// Find MV stack process (7.10.2).
pub(crate) fn find_mv_stack(&mut self, is_compound: bool) {
let bw4 = self.b.bw4 as isize;
let bh4 = self.b.bh4 as isize;
let mut s = MvStack { global_mvs: [self.setup_global_mv(0), [0, 0]], ..Default::default() };
if is_compound {
s.global_mvs[1] = self.setup_global_mv(1);
}
self.mvs = s;
self.mvs.found_match = false;
self.scan_row(-1, is_compound);
let mut found_above = self.mvs.found_match;
self.mvs.found_match = false;
self.scan_col(-1, is_compound);
let mut found_left = self.mvs.found_match;
self.mvs.found_match = false;
if bw4.max(bh4) <= 16 {
self.scan_point(-1, bw4, is_compound);
}
if self.mvs.found_match {
found_above = true;
}
self.mvs.close_matches = found_above as usize + found_left as usize;
let num_nearest = self.mvs.num_mv_found;
let num_new = self.mvs.new_mv_count;
for idx in 0..num_nearest {
self.mvs.weight_stack[idx] += REF_CAT_LEVEL as u32;
}
self.mvs.zero_mv_context = 0;
if self.fs.fh.use_ref_frame_mvs {
self.temporal_scan(is_compound);
}
self.scan_point(-1, -1, is_compound);
if self.mvs.found_match {
found_above = true;
}
self.mvs.found_match = false;
self.scan_row(-3, is_compound);
if self.mvs.found_match {
found_above = true;
}
self.mvs.found_match = false;
self.scan_col(-3, is_compound);
if self.mvs.found_match {
found_left = true;
}
self.mvs.found_match = false;
if bh4 > 1 {
self.scan_row(-5, is_compound);
}
if self.mvs.found_match {
found_above = true;
}
self.mvs.found_match = false;
if bw4 > 1 {
self.scan_col(-5, is_compound);
}
if self.mvs.found_match {
found_left = true;
}
self.mvs.total_matches = found_above as usize + found_left as usize;
let n = self.mvs.num_mv_found;
self.sort_stack(0, num_nearest, is_compound);
self.sort_stack(num_nearest, n, is_compound);
if self.mvs.num_mv_found < 2 {
self.extra_search(is_compound);
}
self.context_and_clamping(is_compound, num_new);
}
fn scan_row(&mut self, delta_row: isize, is_compound: bool) {
let bw4 = self.b.bw4 as isize;
let (mi_row, mi_col) = (self.b.mi_row as isize, self.b.mi_col as isize);
let mi_cols = self.fs.fh.mi_cols as isize;
let end4 = bw4.min(mi_cols - mi_col).min(16);
let mut delta_col = 0;
let use_step16 = bw4 >= 16;
let mut delta_row = delta_row;
if delta_row.abs() > 1 {
delta_row += mi_row & 1;
delta_col = 1 - (mi_col & 1);
}
let mut i = 0;
while i < end4 {
let mv_row = mi_row + delta_row;
let mv_col = mi_col + delta_col + i;
if !self.inside(mv_row, mv_col) {
break;
}
let mi = &self.t.mi;
let mut len = bw4.min(NUM_4X4_BLOCKS_WIDE[mi.mi_size[mi.idx(mv_row as usize, mv_col as usize)] as usize] as isize);
if delta_row.abs() > 1 {
len = len.max(2);
}
if use_step16 {
len = len.max(4);
}
let weight = len as u32 * 2;
self.add_ref_mv_candidate(mv_row as usize, mv_col as usize, is_compound, weight);
i += len;
}
}
fn scan_col(&mut self, delta_col: isize, is_compound: bool) {
let bh4 = self.b.bh4 as isize;
let (mi_row, mi_col) = (self.b.mi_row as isize, self.b.mi_col as isize);
let mi_rows = self.fs.fh.mi_rows as isize;
let end4 = bh4.min(mi_rows - mi_row).min(16);
let mut delta_row = 0;
let use_step16 = bh4 >= 16;
let mut delta_col = delta_col;
if delta_col.abs() > 1 {
delta_row = 1 - (mi_row & 1);
delta_col += mi_col & 1;
}
let mut i = 0;
while i < end4 {
let mv_row = mi_row + delta_row + i;
let mv_col = mi_col + delta_col;
if !self.inside(mv_row, mv_col) {
break;
}
let mi = &self.t.mi;
let mut len = bh4.min(NUM_4X4_BLOCKS_HIGH[mi.mi_size[mi.idx(mv_row as usize, mv_col as usize)] as usize] as isize);
if delta_col.abs() > 1 {
len = len.max(2);
}
if use_step16 {
len = len.max(4);
}
let weight = len as u32 * 2;
self.add_ref_mv_candidate(mv_row as usize, mv_col as usize, is_compound, weight);
i += len;
}
}
fn scan_point(&mut self, delta_row: isize, delta_col: isize, is_compound: bool) {
let mv_row = self.b.mi_row as isize + delta_row;
let mv_col = self.b.mi_col as isize + delta_col;
if self.inside(mv_row, mv_col) && self.t.mi.written[self.t.mi.idx(mv_row as usize, mv_col as usize)] {
self.add_ref_mv_candidate(mv_row as usize, mv_col as usize, is_compound, 4);
}
}
fn temporal_scan(&mut self, is_compound: bool) {
let bw4 = self.b.bw4 as isize;
let bh4 = self.b.bh4 as isize;
let step_w4 = if bw4 >= 16 { 4 } else { 2 };
let step_h4 = if bh4 >= 16 { 4 } else { 2 };
let mut dr = 0;
while dr < bh4.min(16) {
let mut dc = 0;
while dc < bw4.min(16) {
self.add_tpl_ref_mv(dr, dc, is_compound);
dc += step_w4;
}
dr += step_h4;
}
let allow_extension = (2..16).contains(&bh4) && (2..16).contains(&bw4);
if allow_extension {
let pos = [(bh4, -2), (bh4, bw4), (bh4 - 2, bw4)];
for &(dr, dc) in &pos {
let row = (self.b.mi_row as isize & 15) + dr;
let col = (self.b.mi_col as isize & 15) + dc;
if (0..16).contains(&row) && (0..16).contains(&col) {
self.add_tpl_ref_mv(dr, dc, is_compound);
}
}
}
}
fn add_tpl_ref_mv(&mut self, delta_row: isize, delta_col: isize, is_compound: bool) {
let mv_row = (self.b.mi_row as isize + delta_row) | 1;
let mv_col = (self.b.mi_col as isize + delta_col) | 1;
if !self.inside(mv_row, mv_col) {
return;
}
let x8 = (mv_col >> 1) as usize;
let y8 = (mv_row >> 1) as usize;
let w8 = self.fs.fh.mi_cols as usize >> 1;
if delta_row == 0 && delta_col == 0 {
self.mvs.zero_mv_context = 1;
}
let rf0 = self.b.ref_frame[0] as usize;
if !is_compound {
let mut cand = self.fs.motion_field[rf0][y8 * w8 + x8];
if cand[0] == INVALID_MV {
return;
}
self.lower_mv_precision(&mut cand);
if delta_row == 0 && delta_col == 0 {
let g = self.mvs.global_mvs[0];
self.mvs.zero_mv_context = ((cand[0] - g[0]).abs() >= 16 || (cand[1] - g[1]).abs() >= 16) as usize;
}
let n = self.mvs.num_mv_found;
let idx = (0..n).find(|&i| self.mvs.ref_stack_mv[i][0] == cand);
if let Some(i) = idx {
self.mvs.weight_stack[i] += 2;
} else if n < MAX_REF_MV_STACK_SIZE {
self.mvs.ref_stack_mv[n][0] = cand;
self.mvs.weight_stack[n] = 2;
self.mvs.num_mv_found += 1;
}
} else {
let rf1 = self.b.ref_frame[1] as usize;
let mut c0 = self.fs.motion_field[rf0][y8 * w8 + x8];
if c0[0] == INVALID_MV {
return;
}
let mut c1 = self.fs.motion_field[rf1][y8 * w8 + x8];
if c1[0] == INVALID_MV {
return;
}
self.lower_mv_precision(&mut c0);
self.lower_mv_precision(&mut c1);
if delta_row == 0 && delta_col == 0 {
let g = self.mvs.global_mvs;
self.mvs.zero_mv_context = ((c0[0] - g[0][0]).abs() >= 16
|| (c0[1] - g[0][1]).abs() >= 16
|| (c1[0] - g[1][0]).abs() >= 16
|| (c1[1] - g[1][1]).abs() >= 16) as usize;
}
let n = self.mvs.num_mv_found;
let idx = (0..n).find(|&i| self.mvs.ref_stack_mv[i][0] == c0 && self.mvs.ref_stack_mv[i][1] == c1);
if let Some(i) = idx {
self.mvs.weight_stack[i] += 2;
} else if n < MAX_REF_MV_STACK_SIZE {
self.mvs.ref_stack_mv[n] = [c0, c1];
self.mvs.weight_stack[n] = 2;
self.mvs.num_mv_found += 1;
}
}
}
fn add_ref_mv_candidate(&mut self, mv_row: usize, mv_col: usize, is_compound: bool, weight: u32) {
let mi = &self.t.mi;
let i = mi.idx(mv_row, mv_col);
if !mi.is_inter[i] {
return;
}
let rf = mi.ref_frame[i];
if !is_compound {
for cand_list in 0..2 {
if rf[cand_list] == self.b.ref_frame[0] {
self.search_stack(mv_row, mv_col, cand_list, weight);
}
}
} else if rf[0] == self.b.ref_frame[0] && rf[1] == self.b.ref_frame[1] {
self.compound_search_stack(mv_row, mv_col, weight);
}
}
fn search_stack(&mut self, mv_row: usize, mv_col: usize, cand_list: usize, weight: u32) {
let mi = &self.t.mi;
let i = mi.idx(mv_row, mv_col);
let cand_mode = mi.y_mode[i] as usize;
let cand_size = mi.mi_size[i] as usize;
let large = block_width(cand_size).min(block_height(cand_size)) >= 8;
let gm0 = self.fs.fh.gm_type[self.b.ref_frame[0] as usize] as usize;
let mut cand = if (cand_mode == GLOBALMV || cand_mode == GLOBAL_GLOBALMV) && gm0 > TRANSLATION && large {
self.mvs.global_mvs[0]
} else {
let m = mi.mv[i][cand_list];
[m.row as i32, m.col as i32]
};
self.lower_mv_precision(&mut cand);
if has_newmv(cand_mode) {
self.mvs.new_mv_count += 1;
}
self.mvs.found_match = true;
let n = self.mvs.num_mv_found;
if let Some(idx) = (0..n).find(|&k| self.mvs.ref_stack_mv[k][0] == cand) {
self.mvs.weight_stack[idx] += weight;
} else if n < MAX_REF_MV_STACK_SIZE {
self.mvs.ref_stack_mv[n][0] = cand;
self.mvs.weight_stack[n] = weight;
self.mvs.num_mv_found += 1;
}
}
fn compound_search_stack(&mut self, mv_row: usize, mv_col: usize, weight: u32) {
let mi = &self.t.mi;
let i = mi.idx(mv_row, mv_col);
let mut cand = [[mi.mv[i][0].row as i32, mi.mv[i][0].col as i32], [mi.mv[i][1].row as i32, mi.mv[i][1].col as i32]];
let cand_mode = mi.y_mode[i] as usize;
if cand_mode == GLOBAL_GLOBALMV {
for (rl, c) in cand.iter_mut().enumerate() {
if self.fs.fh.gm_type[self.b.ref_frame[rl] as usize] as usize > TRANSLATION {
*c = self.mvs.global_mvs[rl];
}
}
}
for c in cand.iter_mut() {
self.lower_mv_precision(c);
}
self.mvs.found_match = true;
let n = self.mvs.num_mv_found;
if let Some(idx) = (0..n).find(|&k| self.mvs.ref_stack_mv[k][0] == cand[0] && self.mvs.ref_stack_mv[k][1] == cand[1]) {
self.mvs.weight_stack[idx] += weight;
} else if n < MAX_REF_MV_STACK_SIZE {
self.mvs.ref_stack_mv[n] = cand;
self.mvs.weight_stack[n] = weight;
self.mvs.num_mv_found += 1;
}
if has_newmv(cand_mode) {
self.mvs.new_mv_count += 1;
}
}
fn sort_stack(&mut self, start: usize, end: usize, is_compound: bool) {
let mut end = end;
let s = &mut self.mvs;
while end > start {
let mut new_end = start;
for idx in start + 1..end {
if s.weight_stack[idx - 1] < s.weight_stack[idx] {
s.weight_stack.swap(idx - 1, idx);
for list in 0..1 + is_compound as usize {
let t = s.ref_stack_mv[idx - 1][list];
s.ref_stack_mv[idx - 1][list] = s.ref_stack_mv[idx][list];
s.ref_stack_mv[idx][list] = t;
}
new_end = idx;
}
}
end = new_end;
}
}
fn extra_search(&mut self, is_compound: bool) {
self.mvs.ref_id_count = [0; 2];
self.mvs.ref_diff_count = [0; 2];
let (mi_row, mi_col) = (self.b.mi_row as isize, self.b.mi_col as isize);
let mut w4 = (self.b.bw4 as isize).min(16);
let mut h4 = (self.b.bh4 as isize).min(16);
w4 = w4.min(self.fs.fh.mi_cols as isize - mi_col);
h4 = h4.min(self.fs.fh.mi_rows as isize - mi_row);
let num4x4 = w4.min(h4);
for pass in 0..2 {
let mut idx = 0;
while idx < num4x4 && self.mvs.num_mv_found < 2 {
let (mv_row, mv_col) = if pass == 0 { (mi_row - 1, mi_col + idx) } else { (mi_row + idx, mi_col - 1) };
if !self.inside(mv_row, mv_col) {
break;
}
self.add_extra_mv_candidate(mv_row as usize, mv_col as usize, is_compound);
let mi = &self.t.mi;
let sz = mi.mi_size[mi.idx(mv_row as usize, mv_col as usize)] as usize;
idx += if pass == 0 { NUM_4X4_BLOCKS_WIDE[sz] } else { NUM_4X4_BLOCKS_HIGH[sz] } as isize;
}
}
if is_compound {
let mut combined = [[[0i32; 2]; 2]; 2];
for list in 0..2 {
let mut comp_count = 0;
for idx in 0..self.mvs.ref_id_count[list] {
combined[comp_count][list] = self.mvs.ref_id_mvs[list][idx];
comp_count += 1;
}
let mut idx = 0;
while idx < self.mvs.ref_diff_count[list] && comp_count < 2 {
combined[comp_count][list] = self.mvs.ref_diff_mvs[list][idx];
comp_count += 1;
idx += 1;
}
while comp_count < 2 {
combined[comp_count][list] = self.mvs.global_mvs[list];
comp_count += 1;
}
}
let n = self.mvs.num_mv_found;
if n == 1 {
if combined[0][0] == self.mvs.ref_stack_mv[0][0] && combined[0][1] == self.mvs.ref_stack_mv[0][1] {
self.mvs.ref_stack_mv[n] = combined[1];
} else {
self.mvs.ref_stack_mv[n] = combined[0];
}
self.mvs.weight_stack[n] = 2;
self.mvs.num_mv_found += 1;
} else {
for c in combined {
let n = self.mvs.num_mv_found;
self.mvs.ref_stack_mv[n] = c;
self.mvs.weight_stack[n] = 2;
self.mvs.num_mv_found += 1;
}
}
} else {
for idx in self.mvs.num_mv_found..2 {
self.mvs.ref_stack_mv[idx][0] = self.mvs.global_mvs[0];
}
}
}
fn add_extra_mv_candidate(&mut self, mv_row: usize, mv_col: usize, is_compound: bool) {
let mi = &self.t.mi;
let i = mi.idx(mv_row, mv_col);
let rfs = mi.ref_frame[i];
let mvs = mi.mv[i];
let bias = &self.fs.fh.ref_frame_sign_bias;
if is_compound {
for cand_list in 0..2 {
let cand_ref = rfs[cand_list];
if cand_ref > INTRA_FRAME as i8 {
for list in 0..2 {
let mut cand = [mvs[cand_list].row as i32, mvs[cand_list].col as i32];
if cand_ref == self.b.ref_frame[list] && self.mvs.ref_id_count[list] < 2 {
let k = self.mvs.ref_id_count[list];
self.mvs.ref_id_mvs[list][k] = cand;
self.mvs.ref_id_count[list] += 1;
} else if self.mvs.ref_diff_count[list] < 2 {
if bias[cand_ref as usize] != bias[self.b.ref_frame[list] as usize] {
cand = [-cand[0], -cand[1]];
}
let k = self.mvs.ref_diff_count[list];
self.mvs.ref_diff_mvs[list][k] = cand;
self.mvs.ref_diff_count[list] += 1;
}
}
}
}
} else {
for cand_list in 0..2 {
let cand_ref = rfs[cand_list];
if cand_ref > INTRA_FRAME as i8 {
let mut cand = [mvs[cand_list].row as i32, mvs[cand_list].col as i32];
if bias[cand_ref as usize] != bias[self.b.ref_frame[0] as usize] {
cand = [-cand[0], -cand[1]];
}
let n = self.mvs.num_mv_found;
if !(0..n).any(|k| self.mvs.ref_stack_mv[k][0] == cand) {
self.mvs.ref_stack_mv[n][0] = cand;
self.mvs.weight_stack[n] = 2;
self.mvs.num_mv_found += 1;
}
}
}
}
}
fn context_and_clamping(&mut self, is_compound: bool, num_new: usize) {
let bw = block_width(self.b.mi_size);
let bh = block_height(self.b.mi_size);
let n = self.mvs.num_mv_found;
for idx in 0..n {
let mut z = 0;
if idx + 1 < n {
let w0 = self.mvs.weight_stack[idx];
let w1 = self.mvs.weight_stack[idx + 1];
if w0 >= REF_CAT_LEVEL as u32 {
if w1 < REF_CAT_LEVEL as u32 {
z = 1;
}
} else {
z = 2;
}
}
self.mvs.drl_ctx_stack[idx] = z;
}
for list in 0..1 + is_compound as usize {
for idx in 0..n {
let mut mv = self.mvs.ref_stack_mv[idx][list];
mv[0] = self.clamp_mv_row(mv[0], MV_BORDER as i32 + bh * 8);
mv[1] = self.clamp_mv_col(mv[1], MV_BORDER as i32 + bw * 8);
self.mvs.ref_stack_mv[idx][list] = mv;
}
}
let (close, total) = (self.mvs.close_matches, self.mvs.total_matches);
if close == 0 {
self.mvs.new_mv_context = total.min(1);
self.mvs.ref_mv_context = total;
} else if close == 1 {
self.mvs.new_mv_context = 3 - num_new.min(1);
self.mvs.ref_mv_context = 2 + total;
} else {
self.mvs.new_mv_context = 5 - num_new.min(1);
self.mvs.ref_mv_context = 5;
}
}
pub(crate) fn clamp_mv_row(&self, mvec: i32, border: i32) -> i32 {
let bh4 = self.b.bh4 as i32;
let to_top = -((self.b.mi_row as i32 * 4) * 8);
let to_bottom = ((self.fs.fh.mi_rows as i32 - bh4 - self.b.mi_row as i32) * 4) * 8;
mvec.clamp(to_top - border, to_bottom + border)
}
pub(crate) fn clamp_mv_col(&self, mvec: i32, border: i32) -> i32 {
let bw4 = self.b.bw4 as i32;
let to_left = -((self.b.mi_col as i32 * 4) * 8);
let to_right = ((self.fs.fh.mi_cols as i32 - bw4 - self.b.mi_col as i32) * 4) * 8;
mvec.clamp(to_left - border, to_right + border)
}
/// has_overlappable_candidates( ) (7.10.3)
pub(crate) fn has_overlappable_candidates(&self) -> bool {
let mi = &self.t.mi;
let (mi_rows, mi_cols) = (self.fs.fh.mi_rows as usize, self.fs.fh.mi_cols as usize);
if self.b.avail_u {
let mut x4 = self.b.mi_col;
while x4 < mi_cols.min(self.b.mi_col + self.b.bw4) {
let c = (x4 | 1).min(mi_cols - 1);
if mi.ref_frame[mi.idx(self.b.mi_row - 1, c)][0] > INTRA_FRAME as i8 {
return true;
}
x4 += 2;
}
}
if self.b.avail_l {
let mut y4 = self.b.mi_row;
while y4 < mi_rows.min(self.b.mi_row + self.b.bh4) {
let r = (y4 | 1).min(mi_rows - 1);
if mi.ref_frame[mi.idx(r, self.b.mi_col - 1)][0] > INTRA_FRAME as i8 {
return true;
}
y4 += 2;
}
}
false
}
/// Find warp samples process (7.10.4).
pub(crate) fn find_warp_samples(&mut self) {
self.mvs.num_samples = 0;
self.mvs.num_samples_scanned = 0;
let w4 = self.b.bw4 as isize;
let h4 = self.b.bh4 as isize;
let (mi_row, mi_col) = (self.b.mi_row as isize, self.b.mi_col as isize);
let (mi_rows, mi_cols) = (self.fs.fh.mi_rows as isize, self.fs.fh.mi_cols as isize);
let mut do_top_left = true;
let mut do_top_right = true;
if self.b.avail_u {
let mi = &self.t.mi;
let src_w = NUM_4X4_BLOCKS_WIDE[mi.mi_size[mi.idx(mi_row as usize - 1, mi_col as usize)] as usize] as isize;
if w4 <= src_w {
let col_offset = -(mi_col & (src_w - 1));
if col_offset < 0 {
do_top_left = false;
}
if col_offset + src_w > w4 {
do_top_right = false;
}
self.add_sample(-1, 0);
} else {
let mut i = 0;
while i < w4.min(mi_cols - mi_col) {
let mi = &self.t.mi;
let src_w = NUM_4X4_BLOCKS_WIDE[mi.mi_size[mi.idx(mi_row as usize - 1, (mi_col + i) as usize)] as usize] as isize;
let step = w4.min(src_w);
self.add_sample(-1, i);
i += step;
}
}
}
if self.b.avail_l {
let mi = &self.t.mi;
let src_h = NUM_4X4_BLOCKS_HIGH[mi.mi_size[mi.idx(mi_row as usize, mi_col as usize - 1)] as usize] as isize;
if h4 <= src_h {
let row_offset = -(mi_row & (src_h - 1));
if row_offset < 0 {
do_top_left = false;
}
self.add_sample(0, -1);
} else {
let mut i = 0;
while i < h4.min(mi_rows - mi_row) {
let mi = &self.t.mi;
let src_h = NUM_4X4_BLOCKS_HIGH[mi.mi_size[mi.idx((mi_row + i) as usize, mi_col as usize - 1)] as usize] as isize;
let step = h4.min(src_h);
self.add_sample(i, -1);
i += step;
}
}
}
if do_top_left {
self.add_sample(-1, -1);
}
if do_top_right && w4.max(h4) <= 16 {
self.add_sample(-1, w4);
}
if self.mvs.num_samples == 0 && self.mvs.num_samples_scanned > 0 {
self.mvs.num_samples = 1;
}
}
fn add_sample(&mut self, delta_row: isize, delta_col: isize) {
if self.mvs.num_samples_scanned >= LEAST_SQUARES_SAMPLES_MAX {
return;
}
let mv_row = self.b.mi_row as isize + delta_row;
let mv_col = self.b.mi_col as isize + delta_col;
if !self.inside(mv_row, mv_col) {
return;
}
let mi = &self.t.mi;
let i = mi.idx(mv_row as usize, mv_col as usize);
if !mi.written[i] {
return;
}
if mi.ref_frame[i][0] != self.b.ref_frame[0] || mi.ref_frame[i][1] != -1 {
return;
}
let cand_sz = mi.mi_size[i] as usize;
let cand_w4 = NUM_4X4_BLOCKS_WIDE[cand_sz] as isize;
let cand_h4 = NUM_4X4_BLOCKS_HIGH[cand_sz] as isize;
let cand_row = mv_row & !(cand_h4 - 1);
let cand_col = mv_col & !(cand_w4 - 1);
let mid_y = cand_row * 4 + cand_h4 * 2 - 1;
let mid_x = cand_col * 4 + cand_w4 * 2 - 1;
let threshold = block_width(self.b.mi_size).max(block_height(self.b.mi_size)).clamp(16, 112);
let cm = mi.mv[mi.idx(cand_row as usize, cand_col as usize)][0];
let mv_diff_row = (cm.row as i32 - self.b.mv_i[0][0]).abs();
let mv_diff_col = (cm.col as i32 - self.b.mv_i[0][1]).abs();
let valid = mv_diff_row + mv_diff_col <= threshold;
let cand = [mid_y as i32 * 8, mid_x as i32 * 8, mid_y as i32 * 8 + cm.row as i32, mid_x as i32 * 8 + cm.col as i32];
self.mvs.num_samples_scanned += 1;
if !valid && self.mvs.num_samples_scanned > 1 {
return;
}
self.mvs.cand_list[self.mvs.num_samples] = cand;
if valid {
self.mvs.num_samples += 1;
}
}
#[inline]
pub(crate) fn inside(&self, r: isize, c: isize) -> bool {
c >= self.mi_col_start as isize && c < self.mi_col_end as isize && r >= self.mi_row_start as isize && r < self.mi_row_end as isize
}
}
-72
View File
@@ -1,72 +0,0 @@
//! Optional data parallelism (rayon behind the `threads` feature; sequential otherwise and on
//! wasm32).
#[cfg(feature = "threads")]
use std::sync::Arc;
/// A handle to the decoder's worker pool (or none: run on the calling thread).
#[derive(Clone, Default)]
pub(crate) struct Pool {
#[cfg(feature = "threads")]
pool: Option<Arc<rayon::ThreadPool>>,
}
impl Pool {
/// A pool of `threads` workers (sequential when `threads` <= 1, without the `threads`
/// feature, or on wasm32).
pub fn new(threads: usize) -> Pool {
#[cfg(feature = "threads")]
{
let pool = if threads > 1 && cfg!(not(target_arch = "wasm32")) {
rayon::ThreadPoolBuilder::new().num_threads(threads).thread_name(|i| format!("av1-{i}")).build().ok().map(Arc::new)
} else {
None
};
Pool { pool }
}
#[cfg(not(feature = "threads"))]
{
let _ = threads;
Pool {}
}
}
pub fn is_parallel(&self) -> bool {
#[cfg(feature = "threads")]
{
self.pool.is_some()
}
#[cfg(not(feature = "threads"))]
{
false
}
}
/// `f(i, &mut items[i])` for every item, in parallel when possible; results in order.
pub fn map_mut<T: Send, R: Send>(&self, items: &mut [T], f: impl Fn(usize, &mut T) -> R + Sync + Send) -> Vec<R> {
#[cfg(feature = "threads")]
if let Some(pool) = &self.pool
&& items.len() > 1
{
use rayon::prelude::*;
return pool.install(|| items.par_iter_mut().enumerate().map(|(i, t)| f(i, t)).collect());
}
items.iter_mut().enumerate().map(|(i, t)| f(i, t)).collect()
}
/// `f(i, chunk)` for the consecutive `chunk_len`-element chunks of `data`.
pub fn chunks<T: Send>(&self, data: &mut [T], chunk_len: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
let chunk_len = chunk_len.max(1);
#[cfg(feature = "threads")]
if let Some(pool) = &self.pool
&& data.len() > chunk_len
{
use rayon::prelude::*;
pool.install(|| data.par_chunks_mut(chunk_len).enumerate().for_each(|(i, c)| f(i, c)));
return;
}
for (i, c) in data.chunks_mut(chunk_len).enumerate() {
f(i, c);
}
}
}
-269
View File
@@ -1,269 +0,0 @@
//! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15),
//! super-resolution upscaling (7.16) and loop restoration (7.17).
use crate::spec_tables::{
GLOBAL_GLOBALMV, GLOBALMV, INTRA_FRAME, MAX_LOOP_FILTER, NEARESTMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SEG_LVL_ALT_LF_Y_V, TX_HEIGHT,
TX_WIDTH,
};
use crate::state::FrameState;
use crate::stats::{DecodeStats, Stage, Timer};
/// The post-filters run on the frame worker; `_pool` is for row-parallel filtering (not yet used).
/// `draft`: only super-resolution (which sets the output size) runs.
pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate::par::Pool, draft: bool) {
let mut t = Timer::start();
let lvl = fs.fh.lf.level;
if (lvl[0] != 0 || lvl[1] != 0) && !draft {
loop_filter(fs);
}
stats.add(Stage::LoopFilter, t.lap());
let cdef = if draft { None } else { crate::cdef::apply(fs) };
stats.add(Stage::Cdef, t.lap());
let (up_cur, up_cdef) = if fs.fh.use_superres {
let c = crate::restoration::upscale(fs, &fs.cur);
let d = cdef.as_ref().map(|f| crate::restoration::upscale(fs, f));
(c, d)
} else {
(std::mem::take(&mut fs.cur), cdef)
};
stats.add(Stage::Superres, t.lap());
fs.cur = if fs.fh.lr.uses_lr && !draft {
crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur))
} else {
up_cdef.unwrap_or(up_cur)
};
stats.add(Stage::Restoration, t.lap());
}
// ---------------------------------------------------------------------------------------------
// Loop filter (7.14)
fn loop_filter(fs: &mut FrameState) {
let (mi_rows, mi_cols) = (fs.fh.mi_rows as usize, fs.fh.mi_cols as usize);
for plane in 0..fs.num_planes {
if plane == 0 || fs.fh.lf.level[1 + plane] != 0 {
for pass in 0..2 {
let row_step = if plane == 0 { 1 } else { 1 << fs.ssy };
let col_step = if plane == 0 { 1 } else { 1 << fs.ssx };
let mut row = 0;
while row < mi_rows {
let mut col = 0;
while col < mi_cols {
edge(fs, plane, pass, row, col);
col += col_step;
}
row += row_step;
}
}
}
}
}
fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize) {
let (sub_x, sub_y) = if plane == 0 { (0, 0) } else { (fs.ssx, fs.ssy) };
let (dx, dy) = if pass == 0 { (1i32, 0i32) } else { (0, 1) };
let x = col * 4;
let y = row * 4;
let row = row | sub_y;
let col = col | sub_x;
let on_screen = !(x >= fs.fh.frame_width as usize || y >= fs.fh.frame_height as usize || (pass == 0 && x == 0) || (pass == 1 && y == 0));
if !on_screen {
return;
}
let xp = x >> sub_x;
let yp = y >> sub_y;
let prev_row = row - ((dy as usize) << sub_y);
let prev_col = col - ((dx as usize) << sub_x);
let tx_sz = fs.lf_tx_size(plane, row >> sub_y, col >> sub_x);
let is_tx_edge = if pass == 0 { xp.is_multiple_of(TX_WIDTH[tx_sz] as usize) } else { yp.is_multiple_of(TX_HEIGHT[tx_sz] as usize) };
if !is_tx_edge {
return;
}
let mi = &fs.mi;
let i = mi.idx(row, col);
let mi_size = mi.mi_size[i] as usize;
let plane_size = fs.plane_residual_size(mi_size, plane);
let skip = mi.skip[i];
let is_intra = mi.ref_frame[i][0] <= INTRA_FRAME as i8;
let is_block_edge = if pass == 0 {
xp.is_multiple_of(4 * NUM_4X4_BLOCKS_WIDE[plane_size] as usize)
} else {
yp.is_multiple_of(4 * NUM_4X4_BLOCKS_HIGH[plane_size] as usize)
};
if !(is_block_edge || !skip || is_intra) {
return;
}
let prev_tx_sz = fs.lf_tx_size(plane, prev_row >> sub_y, prev_col >> sub_x);
// filter size (7.14.3)
let base_size = if pass == 0 { TX_WIDTH[prev_tx_sz].min(TX_WIDTH[tx_sz]) } else { TX_HEIGHT[prev_tx_sz].min(TX_HEIGHT[tx_sz]) } as usize;
let filter_size = if plane == 0 { 16.min(base_size) } else { 8.min(base_size) };
let (mut lvl, mut limit, mut blimit, mut thresh) = strength(fs, row, col, plane, pass);
if lvl == 0 {
(lvl, limit, blimit, thresh) = strength(fs, prev_row, prev_col, plane, pass);
}
if lvl == 0 {
return;
}
let t = LfThresh::new(limit, blimit, thresh, fs.bit_depth);
let pl = &mut fs.cur.planes[plane];
let stride = pl.stride;
let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) };
let base = yp * stride + xp;
let chroma = plane > 0;
for k in 0..4 {
sample_filter(&mut pl.data, base + k * along, step, chroma, filter_size, t);
}
}
/// Adaptive filter strength (7.14.4): (lvl, limit, blimit, thresh).
fn strength(fs: &FrameState, row: usize, col: usize, plane: usize, pass: usize) -> (i32, i32, i32, i32) {
let mi = &fs.mi;
let i = mi.idx(row, col);
let segment = mi.segment_id[i] as usize;
let rf = mi.ref_frame[i][0];
let mode = mi.y_mode[i] as usize;
let mode_type = (mode >= NEARESTMV && mode != GLOBALMV && mode != GLOBAL_GLOBALMV) as usize;
let delta_lf = if fs.fh.delta_lf_multi { mi.delta_lf[i][if plane == 0 { pass } else { plane + 1 }] } else { mi.delta_lf[i][0] } as i32;
// 7.14.5
let idx = if plane == 0 { pass } else { plane + 1 };
let base = (delta_lf + fs.fh.lf.level[idx] as i32).clamp(0, MAX_LOOP_FILTER as i32);
let mut lvl_seg = base;
let feature = SEG_LVL_ALT_LF_Y_V + idx;
if fs.fh.seg.enabled && fs.fh.seg.features.enabled[segment][feature] {
lvl_seg = (fs.fh.seg.features.data[segment][feature] + lvl_seg).clamp(0, MAX_LOOP_FILTER as i32);
}
if fs.fh.lf.delta_enabled {
let n_shift = lvl_seg >> 5;
let d = &fs.fh.lf.deltas;
if rf == INTRA_FRAME as i8 {
lvl_seg += d.ref_deltas[INTRA_FRAME] << n_shift;
} else if rf > INTRA_FRAME as i8 {
lvl_seg += (d.ref_deltas[rf as usize] << n_shift) + (d.mode_deltas[mode_type] << n_shift);
}
lvl_seg = lvl_seg.clamp(0, MAX_LOOP_FILTER as i32);
}
let lvl = lvl_seg;
let sharp = fs.fh.lf.sharpness as i32;
let shift = if sharp > 4 {
2
} else if sharp > 0 {
1
} else {
0
};
let limit = if sharp > 0 { (lvl >> shift).clamp(1, 9 - sharp) } else { (lvl >> shift).max(1) };
let blimit = 2 * (lvl + 2) + limit;
let thresh = lvl >> 4;
(lvl, limit, blimit, thresh)
}
/// Per-edge thresholds of the sample filter, scaled to the bit depth.
#[derive(Clone, Copy)]
struct LfThresh {
limit: i32,
blimit: i32,
thresh: i32,
/// Flatness threshold 1 << (BitDepth - 8).
flat: i32,
/// 0x80 << (BitDepth - 8) and 1 << (BitDepth - 1).
off: i32,
half: i32,
}
impl LfThresh {
fn new(limit: i32, blimit: i32, thresh: i32, bd: u32) -> LfThresh {
let sh = bd - 8;
LfThresh { limit: limit << sh, blimit: blimit << sh, thresh: thresh << sh, flat: 1 << sh, off: 0x80 << sh, half: 1 << (bd - 1) }
}
}
/// Sample filtering process (7.14.6) for one line across the edge: `base` indexes q0 and
/// `step` is the distance between samples across the edge.
#[allow(clippy::too_many_arguments)]
#[inline(always)]
fn sample_filter(d: &mut [u16], base: usize, step: usize, chroma: bool, filter_size: usize, t: LfThresh) {
let at = |d: &[u16], k: isize| -> i32 { d[(base as isize + k * step as isize) as usize] as i32 };
let (q0, q1, p0, p1) = (at(d, 0), at(d, 1), at(d, -1), at(d, -2));
let lim = t.limit;
// filter mask (7.14.6.2)
if (p1 - p0).abs() > lim || (q1 - q0).abs() > lim || (p0 - q0).abs() * 2 + (p1 - q1).abs() / 2 > t.blimit {
return;
}
let hev = (p1 - p0).abs() > t.thresh || (q1 - q0).abs() > t.thresh;
let mut flat = false;
if filter_size > 4 {
let (q2, p2) = (at(d, 2), at(d, -3));
if (p2 - p1).abs() > lim || (q2 - q1).abs() > lim {
return;
}
let tf = t.flat;
flat = (p1 - p0).abs() <= tf && (q1 - q0).abs() <= tf && (p2 - p0).abs() <= tf && (q2 - q0).abs() <= tf;
if !chroma {
// filterLen >= 8
let (q3, p3) = (at(d, 3), at(d, -4));
if (p3 - p2).abs() > lim || (q3 - q2).abs() > lim {
return;
}
flat = flat && (p3 - p0).abs() <= tf && (q3 - q0).abs() <= tf;
}
}
if !flat {
// narrow filter (7.14.6.3)
let c = |v: i32| v.clamp(-t.half, t.half - 1);
let off = t.off;
let (ps1, ps0, qs0, qs1) = (p1 - off, p0 - off, q0 - off, q1 - off);
let mut filter = if hev { c(ps1 - qs1) } else { 0 };
filter = c(filter + 3 * (qs0 - ps0));
let filter1 = c(filter + 4) >> 3;
let filter2 = c(filter + 3) >> 3;
d[base] = (c(qs0 - filter1) + off) as u16;
d[base - step] = (c(ps0 + filter2) + off) as u16;
if !hev {
let f = (filter1 + 1) >> 1;
d[base + step] = (c(qs1 - f) + off) as u16;
d[base - 2 * step] = (c(ps1 + f) + off) as u16;
}
return;
}
if chroma {
wide::<2, 1, 3>(d, base, step);
} else if filter_size == 8 {
wide::<3, 0, 3>(d, base, step);
} else {
let tf = t.flat;
let q0 = at(d, 0);
let p0 = at(d, -1);
let flat2 = (4..7).all(|k| (at(d, k) - q0).abs() <= tf && (at(d, -k - 1) - p0).abs() <= tf);
if flat2 { wide::<6, 1, 4>(d, base, step) } else { wide::<3, 0, 3>(d, base, step) }
}
}
/// Wide filter process (7.14.6.4) with n = N, the 2-weight taps |j| <= N2 and log2Size = LOG2.
#[inline(always)]
fn wide<const N: usize, const N2: usize, const LOG2: u32>(d: &mut [u16], base: usize, step: usize) {
// v[k] = sample at offset k - (N + 1) from q0, k in 0 .. 2N + 2
let mut v = [0i32; 14];
let first = base - (N + 1) * step;
for (k, x) in v[..2 * N + 2].iter_mut().enumerate() {
*x = d[first + k * step] as i32;
}
// e[m] = v[clamp(m - N, 0, 2N + 1)]: the clamped tap positions as one array, so
// F[i] = sum_{jj = 0}^{2N} e[i + 1 + jj] * (2 if |jj - N| <= N2 else 1), a sliding window.
let mut e = [0i32; 25];
for (m, x) in e[..4 * N + 1].iter_mut().enumerate() {
*x = v[m.saturating_sub(N).min(2 * N + 1)];
}
let mut f = [0i32; 12];
let mut win: i32 = e[1..2 * N + 2].iter().sum();
for i in 0..2 * N {
if i > 0 {
win += e[i + 1 + 2 * N] - e[i];
}
let centre: i32 = e[i + 1 + N - N2..=i + 1 + N + N2].iter().sum();
f[i] = (win + centre + (1 << (LOG2 - 1))) >> LOG2;
}
let out = base - N * step;
for i in 0..2 * N {
d[out + i * step] = f[i] as u16;
}
}
-298
View File
@@ -1,298 +0,0 @@
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
use crate::frame::{FrameBuf, Plane};
use crate::spec_tables::{
FILTER_BITS, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER, SGR_PARAMS, SGRPROJ_MTABLE_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_RECIP_BITS,
SGRPROJ_RST_BITS, SGRPROJ_SGR_BITS, SUPERRES_EXTRA_BITS, SUPERRES_FILTER_OFFSET, SUPERRES_FILTER_TAPS, SUPERRES_SCALE_BITS, SUPERRES_SCALE_MASK,
UPSCALE_FILTER,
};
use crate::state::FrameState;
/// Upscale a frame horizontally from FrameWidth to UpscaledWidth (7.16).
pub(crate) fn upscale(fs: &FrameState, input: &FrameBuf) -> FrameBuf {
let fh = &fs.fh;
let up_w = fh.upscaled_width as usize;
let mut out = FrameBuf::new(up_w, fh.frame_height as usize, fs.num_planes, fs.ssx, fs.ssy, fs.bit_depth as u8);
let max = (1i32 << fs.bit_depth) - 1;
for plane in 0..fs.num_planes {
let (sub_x, sub_y) = if plane > 0 { (fs.ssx, fs.ssy) } else { (0, 0) };
let down_w = ((fh.frame_width as usize) + sub_x) >> sub_x;
let up_pw = (up_w + sub_x) >> sub_x;
let plane_h = ((fh.frame_height as usize) + sub_y) >> sub_y;
let sb = SUPERRES_SCALE_BITS as i64;
let step_x = (((down_w as i64) << sb) + (up_pw as i64 / 2)) / up_pw as i64;
let err = up_pw as i64 * step_x - ((down_w as i64) << sb);
let mut initial = (-(((up_pw - down_w) as i64) << (sb - 1)) + up_pw as i64 / 2) / up_pw as i64 + (1 << (SUPERRES_EXTRA_BITS - 1)) - err / 2;
initial &= SUPERRES_SCALE_MASK as i64;
let mi_w = fh.mi_cols as usize >> sub_x;
let max_x = (mi_w * 4 - 1) as i64;
let src = &input.planes[plane];
let dst = &mut out.planes[plane];
for y in 0..plane_h {
let row = src.row(y);
for x in 0..up_pw {
let src_x = -(1i64 << sb) + initial + x as i64 * step_x;
let src_px = src_x >> sb;
let subpel = ((src_x & SUPERRES_SCALE_MASK as i64) >> SUPERRES_EXTRA_BITS) as usize;
let mut sum = 0i32;
for k in 0..SUPERRES_FILTER_TAPS {
let sx = (src_px + k as i64 - SUPERRES_FILTER_OFFSET as i64).clamp(0, max_x) as usize;
sum += row[sx] as i32 * UPSCALE_FILTER[subpel][k] as i32;
}
dst.set(x, y, ((sum + 64) >> FILTER_BITS).clamp(0, max) as u16);
}
}
}
out
}
struct LrCtx<'a> {
cur: &'a Plane,
cdef: &'a Plane,
plane_end_x: i32,
plane_end_y: i32,
stripe_start_y: i32,
stripe_end_y: i32,
}
impl LrCtx<'_> {
/// Get source sample process (7.17.6).
#[inline(always)]
fn sample(&self, x: i32, y: i32) -> i32 {
let x = x.min(self.plane_end_x).max(0) as usize;
let y = y.min(self.plane_end_y).max(0);
if y < self.stripe_start_y {
let y = (self.stripe_start_y - 2).max(y) as usize;
self.cur.at(x, y) as i32
} else if y > self.stripe_end_y {
let y = (self.stripe_end_y + 2).min(y) as usize;
self.cur.at(x, y) as i32
} else {
self.cdef.at(x, y as usize) as i32
}
}
}
/// 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 mut lr = cdef.clone();
for plane in 0..fs.num_planes {
if fs.fh.lr.frame_restoration_type[plane] == RESTORE_NONE as u8 {
continue;
}
restore_plane(fs, cur, cdef, &mut lr.planes[plane], plane);
}
lr
}
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];
let mk = |c: &[i8; 3]| -> [i32; 7] {
let mut f = [0i32; 7];
f[3] = 128;
for i in 0..3 {
f[i] = c[i] as i32;
f[6 - i] = c[i] as i32;
f[3] -= 2 * c[i] as i32;
}
f
};
let vfilter = mk(&coef[0]);
let hfilter = mk(&coef[1]);
let fb = FILTER_BITS as u32;
let offset = 1i32 << (bd + fb - round0 - 1);
let limit = (1i32 << (bd + 1 + fb - round0)) - 1;
let mut inter = [[0i32; 4]; 10];
for r in 0..(h + 6) as usize {
for c in 0..w as usize {
let mut s = 0;
for t in 0..7 {
s += hfilter[t] * ctx.sample(x + c as i32 + t as i32 - 3, y + r as i32 - 3);
}
let v = (s + (1 << (round0 - 1))) >> round0;
inter[r][c] = v.clamp(-offset, limit - offset);
}
}
let max = (1i32 << bd) - 1;
for r in 0..h as usize {
for c in 0..w as usize {
let mut s = 0;
for t in 0..7 {
s += vfilter[t] * inter[r + t][c];
}
let v = (s + (1 << (round1 - 1))) >> round1;
out.set(x as usize + c, y as usize + r, v.clamp(0, max) as u16);
}
}
}
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, 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;
let w2 = (1 << SGRPROJ_PRJ_BITS) - w0 - w1;
let r0 = SGR_PARAMS[set][0];
let r1 = SGR_PARAMS[set][2];
let max = (1i32 << bd) - 1;
let sh = (SGRPROJ_RST_BITS + SGRPROJ_PRJ_BITS) as u32;
for i in 0..h as usize {
for j in 0..w as usize {
let u = (ctx.cdef.at(x as usize + j, y as usize + i) as i32) << SGRPROJ_RST_BITS;
let mut v = w1 * u;
v += if r0 != 0 { w0 * flt0[i][j] } else { w0 * u };
v += if r1 != 0 { w2 * flt1[i][j] } else { w2 * u };
let s = (v + (1 << (sh - 1))) >> sh;
out.set(x as usize + j, y as usize + i, s.clamp(0, max) as u16);
}
}
}
#[inline(always)]
fn round2_64(x: i64, n: u32) -> i64 {
if n == 0 { x } else { (x + (1i64 << (n - 1))) >> n }
}
/// 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;
}
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 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 (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] = b2;
}
}
for i in 0..h {
let shift = if pass == 0 && (i & 1) == 1 { 4 } else { 5 };
for j in 0..w {
let mut a = 0i64;
let mut b = 0i64;
for dy in -1..=1i32 {
for dx in -1..=1i32 {
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];
}
}
let v = a * ctx.cdef.at((x + j) as usize, (y + i) as usize) as i64 + b;
f[i as usize][j as usize] = round2_64(v, (SGRPROJ_SGR_BITS + shift - SGRPROJ_RST_BITS) as u32) as i32;
}
}
}
File diff suppressed because it is too large Load Diff
-294
View File
@@ -1,294 +0,0 @@
//! Per-frame decoding state shared by the tiles and the post-filters.
//!
//! [`FrameShared`] holds what tiles only read (headers, references, projected motion field);
//! [`TileState`] what a tile writes (reconstruction, mode info, per-block filter parameters,
//! entropy contexts). With one tile the frame's own buffers move into the tile state and back;
//! with several, each tile decodes into private buffers covering its area (tiles never read
//! outside themselves), which are then copied into the frame.
use crate::decoder::RefFrame;
use crate::frame::{FrameBuf, MiInfo};
use crate::header::{FrameHeader, RefInfo, SequenceHeader};
use crate::spec_tables::{DEFAULT_INTRA_FRAME_Y_MODE_CDF, NUM_REF_FRAMES, RESTORE_NONE, SUBSAMPLED_SIZE};
use std::ops::{Deref, DerefMut};
use std::sync::Arc;
/// Read-only (during tile decoding) frame state.
pub(crate) struct FrameShared {
pub seq: SequenceHeader,
pub fh: FrameHeader,
pub num_planes: usize,
pub ssx: usize,
pub ssy: usize,
pub bit_depth: u32,
pub lr_unit_rows: [usize; 3],
pub lr_unit_cols: [usize; 3],
pub cdef_cols: usize,
/// Reference slots (FrameStore and saved state) used by this frame.
pub refs: [Option<Arc<RefFrame>>; NUM_REF_FRAMES],
pub ref_info: [RefInfo; NUM_REF_FRAMES],
/// MotionFieldMvs[ ref ][ y8 * w8 + x8 ] (7.9); empty when unused.
pub motion_field: [Vec<[i32; 2]>; 8],
/// PrevSegmentIds (mi units).
pub prev_segment_ids: Vec<u8>,
}
impl FrameShared {
pub fn plane_residual_size(&self, subsize: usize, plane: usize) -> usize {
let sx = if plane > 0 { self.ssx } else { 0 };
let sy = if plane > 0 { self.ssy } else { 0 };
SUBSAMPLED_SIZE[subsize][sx][sy] as usize
}
}
/// A byte per 4x4 unit of a plane (LoopfilterTxSizes), possibly for a region only.
#[derive(Clone, Default)]
pub(crate) struct Grid8 {
pub data: Vec<u8>,
pub ox: usize,
pub oy: usize,
pub stride: usize,
pub rows: usize,
}
impl Grid8 {
fn new(ox: usize, oy: usize, stride: usize, rows: usize) -> Grid8 {
Grid8 { data: vec![0; stride * rows], ox, oy, stride, rows }
}
#[inline]
pub fn get(&self, row: usize, col: usize) -> u8 {
self.data[(row - self.oy) * self.stride + col - self.ox]
}
/// Stores outside the grid are dropped (blocks reaching past the allocated margin).
#[inline]
pub fn set(&mut self, row: usize, col: usize, v: u8) {
if col >= self.ox && col < self.ox + self.stride && row >= self.oy && row < self.oy + self.rows {
self.data[(row - self.oy) * self.stride + col - self.ox] = v;
}
}
fn copy_into(&self, dst: &mut Grid8) {
for y in 0..self.rows {
let d = (self.oy + y - dst.oy) * dst.stride + self.ox - dst.ox;
dst.data[d..d + self.stride].copy_from_slice(&self.data[y * self.stride..(y + 1) * self.stride]);
}
}
}
/// Loop restoration parameters per unit.
#[derive(Clone, Default)]
pub(crate) struct LrUnits {
pub lr_type: [Vec<u8>; 3],
pub lr_wiener: [Vec<[[i8; 3]; 2]>; 3],
pub lr_sgr_set: [Vec<u8>; 3],
pub lr_sgr_xqd: [Vec<[i8; 2]>; 3],
}
/// What tile decoding writes.
pub(crate) struct TileState {
pub cur: FrameBuf,
pub mi: MiInfo,
/// cdef_idx per 64x64 block (-1: not coded).
pub cdef: Vec<i8>,
/// LoopfilterTxSizes[ plane ][ row ][ col ] in 4x4 units of each plane.
pub lf_tx_size: [Grid8; 3],
pub lr: LrUnits,
/// LR units this tile coded (per plane), for merging.
pub lr_written: [Vec<usize>; 3],
pub above_level: [Vec<u8>; 3],
pub above_dc: [Vec<u8>; 3],
pub above_seg_pred: Vec<u8>,
pub left_level: [Vec<u8>; 3],
pub left_dc: [Vec<u8>; 3],
pub left_seg_pred: Vec<u8>,
/// TileIntraFrameYModeCdf (reset per tile).
pub intra_frame_y_mode_cdf: [[[u16; 14]; 5]; 5],
cdef_cols: usize,
}
impl TileState {
fn with(cur: FrameBuf, mi: MiInfo, cdef: Vec<i8>, lf_tx_size: [Grid8; 3], lr: LrUnits, cdef_cols: usize) -> TileState {
let (mi_cols, mi_rows) = (mi.cols, mi.rows);
TileState {
cur,
mi,
cdef,
lf_tx_size,
lr,
lr_written: Default::default(),
above_level: std::array::from_fn(|_| vec![0; mi_cols + 64]),
above_dc: std::array::from_fn(|_| vec![0; mi_cols + 64]),
above_seg_pred: vec![0; mi_cols + 64],
left_level: std::array::from_fn(|_| vec![0; mi_rows + 64]),
left_dc: std::array::from_fn(|_| vec![0; mi_rows + 64]),
left_seg_pred: vec![0; mi_rows + 64],
intra_frame_y_mode_cdf: DEFAULT_INTRA_FRAME_Y_MODE_CDF,
cdef_cols,
}
}
#[inline]
pub fn cdef_idx(&self, r: usize, c: usize) -> i8 {
self.cdef[(r >> 4) * self.cdef_cols + (c >> 4)]
}
#[inline]
pub fn set_cdef_idx(&mut self, r: usize, c: usize, v: i8) {
let i = (r >> 4) * self.cdef_cols + (c >> 4);
if i < self.cdef.len() {
self.cdef[i] = v;
}
}
#[inline]
pub fn set_lf_tx_size(&mut self, plane: usize, row: usize, col: usize, v: u8) {
self.lf_tx_size[plane].set(row, col, v);
}
}
pub(crate) struct FrameState {
pub sh: FrameShared,
pub cur: FrameBuf,
pub mi: MiInfo,
cdef: Vec<i8>,
lf_tx_size: [Grid8; 3],
pub lr: LrUnits,
}
impl Deref for FrameState {
type Target = FrameShared;
fn deref(&self) -> &FrameShared {
&self.sh
}
}
impl DerefMut for FrameState {
fn deref_mut(&mut self) -> &mut FrameShared {
&mut self.sh
}
}
fn count_units_in_frame(unit_size: usize, frame_size: usize) -> usize {
((frame_size + (unit_size >> 1)) / unit_size).max(1)
}
impl FrameState {
pub fn new(seq: &SequenceHeader, fh: &FrameHeader) -> FrameState {
let c = &seq.color;
let (ssx, ssy) = (c.subsampling_x as usize, c.subsampling_y as usize);
let mi_cols = fh.mi_cols as usize;
let mi_rows = fh.mi_rows as usize;
let cur = FrameBuf::new(fh.frame_width as usize, fh.frame_height as usize, c.num_planes, ssx, ssy, c.bit_depth);
let cdef_cols = mi_cols.div_ceil(16) + 2;
let cdef_rows = mi_rows.div_ceil(16) + 2;
let mut lr_unit_rows = [0; 3];
let mut lr_unit_cols = [0; 3];
let mut lr = LrUnits::default();
for plane in 0..c.num_planes {
if fh.lr.frame_restoration_type[plane] == RESTORE_NONE as u8 {
continue;
}
let sub_x = if plane == 0 { 0 } else { ssx };
let sub_y = if plane == 0 { 0 } else { ssy };
let unit = fh.lr.loop_restoration_size[plane] as usize;
lr_unit_rows[plane] = count_units_in_frame(unit, (fh.frame_height as usize + sub_y) >> sub_y);
lr_unit_cols[plane] = count_units_in_frame(unit, (fh.upscaled_width as usize + sub_x) >> sub_x);
let n = lr_unit_rows[plane] * lr_unit_cols[plane];
lr.lr_type[plane] = vec![RESTORE_NONE as u8; n];
lr.lr_wiener[plane] = vec![[[0; 3]; 2]; n];
lr.lr_sgr_set[plane] = vec![0; n];
lr.lr_sgr_xqd[plane] = vec![[0; 2]; n];
}
FrameState {
sh: FrameShared {
seq: seq.clone(),
fh: fh.clone(),
num_planes: c.num_planes,
ssx,
ssy,
bit_depth: c.bit_depth as u32,
lr_unit_rows,
lr_unit_cols,
cdef_cols,
refs: Default::default(),
ref_info: Default::default(),
motion_field: Default::default(),
prev_segment_ids: vec![0; mi_cols * mi_rows],
},
cur,
mi: MiInfo::new(mi_cols, mi_rows, fh.allow_screen_content_tools),
cdef: vec![-1; cdef_cols * cdef_rows],
lf_tx_size: std::array::from_fn(|_| Grid8::new(0, 0, mi_cols + 32, mi_rows + 32)),
lr,
}
}
#[inline]
pub fn cdef_idx(&self, r: usize, c: usize) -> i8 {
self.cdef[(r >> 4) * self.cdef_cols + (c >> 4)]
}
#[inline]
pub fn lf_tx_size(&self, plane: usize, row: usize, col: usize) -> usize {
self.lf_tx_size[plane].get(row, col) as usize
}
/// Move the frame's buffers into a tile state covering the whole frame.
pub fn take_tile_state(&mut self) -> TileState {
TileState::with(
std::mem::take(&mut self.cur),
std::mem::take(&mut self.mi),
std::mem::take(&mut self.cdef),
std::mem::take(&mut self.lf_tx_size),
std::mem::take(&mut self.lr),
self.cdef_cols,
)
}
/// Return the buffers taken by [`FrameState::take_tile_state`].
pub fn put_tile_state(&mut self, t: TileState) {
self.cur = t.cur;
self.mi = t.mi;
self.cdef = t.cdef;
self.lf_tx_size = t.lf_tx_size;
self.lr = t.lr;
}
/// Private buffers for the tile covering mode info rows [r0, r1) x columns [c0, c1); tiles
/// on the right / bottom frame edge also cover the allocation margins.
pub fn tile_state(&self, r0: usize, r1: usize, c0: usize, c1: usize) -> TileState {
let (mi_cols, mi_rows) = (self.mi.cols, self.mi.rows);
let x1 = if c1 >= mi_cols { usize::MAX } else { c1 * 4 };
let y1 = if r1 >= mi_rows { usize::MAX } else { r1 * 4 };
let cur = self.cur.region_like(c0 * 4, r0 * 4, x1, y1);
let mi = MiInfo::region(mi_cols, mi_rows, c0, r0, c1 - c0, r1 - r0, !self.mi.palette_size[0].is_empty());
let lf = std::array::from_fn(|p| {
let (sx, sy) = if p == 0 { (0, 0) } else { (self.ssx, self.ssy) };
let full = &self.lf_tx_size[p];
let (gx0, gy0) = (c0 >> sx, r0 >> sy);
let gx1 = if c1 >= mi_cols { full.stride } else { c1 >> sx };
let gy1 = if r1 >= mi_rows { full.rows } else { r1 >> sy };
Grid8::new(gx0, gy0, gx1 - gx0, gy1 - gy0)
});
TileState::with(cur, mi, vec![-1; self.cdef.len()], lf, self.lr.clone(), self.cdef_cols)
}
/// Copy a tile's private buffers into the frame.
pub fn merge_tile_state(&mut self, t: &TileState) {
for p in 0..self.num_planes {
t.cur.planes[p].copy_into(&mut self.cur.planes[p]);
t.lf_tx_size[p].copy_into(&mut self.lf_tx_size[p]);
for &u in &t.lr_written[p] {
self.lr.lr_type[p][u] = t.lr.lr_type[p][u];
self.lr.lr_wiener[p][u] = t.lr.lr_wiener[p][u];
self.lr.lr_sgr_set[p][u] = t.lr.lr_sgr_set[p][u];
self.lr.lr_sgr_xqd[p][u] = t.lr.lr_sgr_xqd[p][u];
}
}
t.mi.copy_into(&mut self.mi);
for (d, &s) in self.cdef.iter_mut().zip(&t.cdef) {
if s != -1 {
*d = s;
}
}
}
}
-114
View File
@@ -1,114 +0,0 @@
//! Decode statistics: frame / tile counts and the busy time of every decoding stage.
/// A decoding stage (see [`DecodeStats::stage_secs`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stage {
/// Frame header follow-up: reference setup, motion field estimation, CDF initialisation.
Setup,
/// Tile decoding: mode info, coefficients, prediction and reconstruction.
Tiles,
/// Deblocking loop filter (7.14).
LoopFilter,
/// CDEF (7.15).
Cdef,
/// Super-resolution upscaling (7.16).
Superres,
/// Loop restoration (7.17).
Restoration,
/// Reference update, motion vector storage, output copy and film grain synthesis.
Output,
}
impl Stage {
pub const COUNT: usize = 7;
pub const ALL: [Stage; Stage::COUNT] =
[Stage::Setup, Stage::Tiles, Stage::LoopFilter, Stage::Cdef, Stage::Superres, Stage::Restoration, Stage::Output];
pub fn name(self) -> &'static str {
match self {
Stage::Setup => "setup",
Stage::Tiles => "tiles",
Stage::LoopFilter => "deblock",
Stage::Cdef => "cdef",
Stage::Superres => "superres",
Stage::Restoration => "restoration",
Stage::Output => "output",
}
}
}
/// Counters accumulated by a [`crate::Decoder`]. Stage times are busy time summed over every
/// thread that worked on the stage (wall-clock per piece of work), so with threads they can add
/// up to more than the elapsed time.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct DecodeStats {
/// Frames decoded (excluding show_existing_frame repeats).
pub frames: u64,
/// Frames decoded in draft mode without in-loop filters.
pub draft_frames: u64,
/// Tiles decoded.
pub tiles: u64,
/// Busy seconds per [`Stage`] (index with `stage as usize`).
pub stage_secs: [f64; Stage::COUNT],
}
impl DecodeStats {
pub fn secs(&self, stage: Stage) -> f64 {
self.stage_secs[stage as usize]
}
pub(crate) fn add(&mut self, stage: Stage, secs: f64) {
self.stage_secs[stage as usize] += secs;
}
pub(crate) fn merge(&mut self, o: &DecodeStats) {
self.frames += o.frames;
self.draft_frames += o.draft_frames;
self.tiles += o.tiles;
for (a, b) in self.stage_secs.iter_mut().zip(o.stage_secs.iter()) {
*a += b;
}
}
/// Sum of all stage times.
pub fn total_secs(&self) -> f64 {
self.stage_secs.iter().sum()
}
}
/// A wall-clock stopwatch (a no-op on wasm32, which has no clock in std).
pub(crate) struct Timer {
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant,
}
impl Timer {
#[inline]
pub fn start() -> Timer {
Timer {
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant::now(),
}
}
/// Seconds since `start` (0 on wasm32).
#[inline]
pub fn secs(&self) -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
self.start.elapsed().as_secs_f64()
}
#[cfg(target_arch = "wasm32")]
{
0.0
}
}
/// Seconds since `start`, restarting the stopwatch.
#[inline]
pub fn lap(&mut self) -> f64 {
let s = self.secs();
*self = Timer::start();
s
}
}
-138
View File
@@ -1,138 +0,0 @@
//! Symbol (arithmetic) decoder, spec 8.2.
//!
//! The state follows the specification literally: `SymbolValue` / `SymbolRange` are 15/16-bit
//! quantities renormalised by reading `bits` new bits at a time, and `SymbolMaxBits` counts the
//! bits still available (going negative while zero padding is consumed).
const EC_PROB_SHIFT: u32 = 6;
const EC_MIN_PROB: u32 = 4;
pub(crate) struct SymbolDecoder<'a> {
data: &'a [u8],
/// Next byte of `data` to load into the window.
next: usize,
/// SymbolValue followed by `k` look-ahead bits: the bitstream with every bit inverted
/// (the spec's renormalisation XORs the new bits with ones), zero bits past the end of
/// the data reading as ones. SymbolValue = win >> k.
win: u64,
k: u32,
range: u32,
pub disable_update: bool,
}
impl<'a> SymbolDecoder<'a> {
/// `init_symbol( sz )` over `data` (the tile's bytes).
pub fn new(data: &'a [u8], disable_update: bool) -> Self {
let mut d = SymbolDecoder { data, next: 0, win: 0, k: 0, range: 1 << 15, disable_update };
// SymbolValue = ((1 << 15) - 1) ^ (first 15 bits): 15 inverted bits.
let mut filled = 0;
while filled < 15 + 32 {
d.win = (d.win << 8) | d.inverted_byte() as u64;
filled += 8;
}
d.k = filled - 15;
d
}
#[inline(always)]
fn inverted_byte(&mut self) -> u8 {
let b = self.data.get(self.next).copied().unwrap_or(0);
self.next += 1;
!b
}
/// `read_symbol( cdf )`: `cdf` has N + 1 entries (the last is the adaptation counter).
#[inline]
pub fn read_symbol(&mut self, cdf: &mut [u16]) -> usize {
let n = cdf.len() - 1;
let symbol = self.decode(cdf, n);
if !self.disable_update {
update_cdf(cdf, n, symbol);
}
symbol
}
#[inline(always)]
fn decode(&mut self, cdf: &[u16], n: usize) -> usize {
let value = (self.win >> self.k) as u32;
let mut cur = self.range;
let mut symbol = 0usize;
let mut prev;
loop {
prev = cur;
let f = (1u32 << 15) - cdf[symbol] as u32;
cur = ((self.range >> 8) * (f >> EC_PROB_SHIFT)) >> (7 - EC_PROB_SHIFT);
cur += EC_MIN_PROB * (n - symbol - 1) as u32;
if value >= cur {
break;
}
symbol += 1;
}
self.range = prev - cur;
self.win -= (cur as u64) << self.k;
self.renormalize();
symbol
}
/// Renormalisation (8.2.6): SymbolValue takes `bits` more (inverted) bits, which are
/// already in the window.
#[inline(always)]
fn renormalize(&mut self) {
let bits = 15 - (31 - self.range.leading_zeros());
self.range <<= bits;
self.k -= bits;
if self.k < 16 {
// SymbolValue < 2^16, so 16 + k <= 64 bits stay in the window.
while self.k <= 40 {
self.win = (self.win << 8) | self.inverted_byte() as u64;
self.k += 8;
}
}
}
/// `read_bool()`
#[inline]
pub fn read_bool(&mut self) -> u32 {
let cdf = [1u16 << 14, 1 << 15, 0];
self.decode(&cdf, 2) as u32
}
/// `read_literal( n )` (L(n))
pub fn read_literal(&mut self, n: u32) -> u32 {
let mut x = 0;
for _ in 0..n {
x = (x << 1) | self.read_bool();
}
x
}
/// NS(n)
pub fn read_ns(&mut self, n: u32) -> u32 {
if n <= 1 {
return 0;
}
let w = 32 - n.leading_zeros();
let m = (1u32 << w) - n;
let v = self.read_literal(w - 1);
if v < m {
return v;
}
let extra = self.read_literal(1);
(v << 1) - m + extra
}
}
/// CDF adaptation (8.2.6).
#[inline(always)]
pub(crate) fn update_cdf(cdf: &mut [u16], n: usize, symbol: usize) {
let count = cdf[n];
let rate = 3 + (count > 15) as u32 + (count > 31) as u32 + (31 - (n as u32).leading_zeros()).min(2);
// Entries below `symbol` move towards 0, the others towards 1 << 15 (the specification's
// comparison with tmp, resolved per entry: c >= 0 and c <= 1 << 15), without branching on
// the decoded symbol.
for (i, e) in cdf[..n - 1].iter_mut().enumerate() {
let c = *e as u32;
*e = if i < symbol { c - (c >> rate) } else { c + (((1 << 15) - c) >> rate) } as u16;
}
cdf[n] += (count < 32) as u16;
}
File diff suppressed because it is too large Load Diff
-780
View File
@@ -1,780 +0,0 @@
//! Inverse transforms (spec 7.13), implemented step by step as specified.
use crate::spec_tables::{
ADST_ADST, ADST_DCT, ADST_FLIPADST, COS128_LOOKUP, DCT_ADST, DCT_DCT, DCT_FLIPADST, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, H_ADST,
H_DCT, H_FLIPADST, TRANSFORM_ROW_SHIFT, TX_HEIGHT_LOG2, TX_WIDTH_LOG2, V_ADST, V_DCT, V_FLIPADST,
};
#[inline(always)]
fn round2_64(x: i64, n: u32) -> i32 {
if n == 0 { x as i32 } else { ((x + (1i64 << (n - 1))) >> n) as i32 }
}
#[inline(always)]
pub(crate) fn round2(x: i32, n: u32) -> i32 {
if n == 0 { x } else { (x + (1 << (n - 1))) >> n }
}
#[inline(always)]
fn cos128(angle: i32) -> i64 {
let a = (angle & 255) as usize;
(match a {
0..=64 => COS128_LOOKUP[a] as i32,
65..=128 => -(COS128_LOOKUP[128 - a] as i32),
129..=192 => -(COS128_LOOKUP[a - 128] as i32),
_ => COS128_LOOKUP[256 - a] as i32,
}) as i64
}
#[inline(always)]
fn sin128(angle: i32) -> i64 {
cos128(angle - 64)
}
/// One value of the 1D transforms, or `L` independent columns / rows of them processed
/// together (lane-wise, so the steps vectorise).
pub(crate) trait Lane: Copy {
const ZERO: Self;
/// (Round2( a * cos - b * sin, 12 ), Round2( a * sin + b * cos, 12 ))
fn rotate(a: Self, b: Self, cos: i32, sin: i32) -> (Self, Self);
/// (Clamp( x + y ), Clamp( x - y )) to [lo, hi]
fn hadamard(x: Self, y: Self, lo: i32, hi: i32) -> (Self, Self);
fn neg(self) -> Self;
/// Round2( v * m, 12 )
fn mul_round12(self, m: i32) -> Self;
fn mul(self, k: i32) -> Self;
fn adst4(t: &mut [Self]);
}
impl Lane for i32 {
const ZERO: i32 = 0;
#[inline(always)]
fn rotate(a: i32, b: i32, cos: i32, sin: i32) -> (i32, i32) {
let (a, b, c, s) = (a as i64, b as i64, cos as i64, sin as i64);
(round2_64(a * c - b * s, 12), round2_64(a * s + b * c, 12))
}
#[inline(always)]
fn hadamard(x: i32, y: i32, lo: i32, hi: i32) -> (i32, i32) {
((x + y).clamp(lo, hi), (x - y).clamp(lo, hi))
}
#[inline(always)]
fn neg(self) -> i32 {
-self
}
#[inline(always)]
fn mul_round12(self, m: i32) -> i32 {
round2_64(self as i64 * m as i64, 12)
}
#[inline(always)]
fn mul(self, k: i32) -> i32 {
self * k
}
fn adst4(t: &mut [i32]) {
inverse_adst4_scalar(t);
}
}
/// Lanes in 32-bit arithmetic: exact for conforming streams up to 10 bits per sample (the
/// spec requires every intermediate value to fit in 8 + BitDepth <= 18 bits, so products with
/// the 12-bit cosines stay below 2^31). Wrapping operations keep corrupt streams from
/// panicking.
impl<const L: usize> Lane for [i32; L] {
const ZERO: Self = [0; L];
#[inline(always)]
fn rotate(a: Self, b: Self, cos: i32, sin: i32) -> (Self, Self) {
let mut x = [0; L];
let mut y = [0; L];
for l in 0..L {
let p = a[l].wrapping_mul(cos).wrapping_sub(b[l].wrapping_mul(sin));
let q = a[l].wrapping_mul(sin).wrapping_add(b[l].wrapping_mul(cos));
x[l] = p.wrapping_add(1 << 11) >> 12;
y[l] = q.wrapping_add(1 << 11) >> 12;
}
(x, y)
}
#[inline(always)]
fn hadamard(x: Self, y: Self, lo: i32, hi: i32) -> (Self, Self) {
let mut a = [0; L];
let mut b = [0; L];
for l in 0..L {
a[l] = x[l].wrapping_add(y[l]).max(lo).min(hi);
b[l] = x[l].wrapping_sub(y[l]).max(lo).min(hi);
}
(a, b)
}
#[inline(always)]
fn neg(self) -> Self {
self.map(i32::wrapping_neg)
}
#[inline(always)]
fn mul_round12(self, m: i32) -> Self {
self.map(|v| v.wrapping_mul(m).wrapping_add(1 << 11) >> 12)
}
#[inline(always)]
fn mul(self, k: i32) -> Self {
self.map(|v| v.wrapping_mul(k))
}
fn adst4(t: &mut [Self]) {
for l in 0..L {
let mut v = [t[0][l], t[1][l], t[2][l], t[3][l]];
inverse_adst4_scalar(&mut v);
for k in 0..4 {
t[k][l] = v[k];
}
}
}
}
fn brev(num_bits: u32, x: usize) -> usize {
let mut t = 0;
for i in 0..num_bits {
let bit = (x >> i) & 1;
t += bit << (num_bits - 1 - i);
}
t
}
/// Butterfly rotation B( a, b, angle, flip ).
#[inline(always)]
fn bf<T: Lane>(t: &mut [T], a: usize, b: usize, angle: i32, flip: bool) {
let (x, y) = T::rotate(t[a], t[b], cos128(angle) as i32, sin128(angle) as i32);
t[a] = x;
t[b] = y;
if flip {
t.swap(a, b);
}
}
/// Hadamard rotation H( a, b, flip, r ).
#[inline(always)]
fn hd<T: Lane>(t: &mut [T], a: usize, b: usize, flip: bool, r: u32) {
let (a, b) = if flip { (b, a) } else { (a, b) };
let lo = -(1i32 << (r - 1));
let hi = (1i32 << (r - 1)) - 1;
let (x, y) = T::hadamard(t[a], t[b], lo, hi);
t[a] = x;
t[b] = y;
}
/// Inverse DCT of length 2^n (7.13.2.3), in place.
pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
// 7.13.2.2 permutation
let len = 1usize << n;
let mut copy = [T::ZERO; 64];
copy[..len].copy_from_slice(&t[..len]);
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);
}
}
if n >= 5 {
for i in 0..8 {
bf(t, 16 + i, 31 - i, 6 + ((brev(3, 7 - i) as i32) << 3), false);
}
}
if n == 6 {
for i in 0..16 {
hd(t, 32 + i * 2, 33 + i * 2, i & 1 == 1, r);
}
}
if n >= 4 {
for i in 0..4 {
bf(t, 8 + i, 15 - i, 12 + ((brev(2, 3 - i) as i32) << 4), false);
}
}
if n >= 5 {
for i in 0..8 {
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 {
bf(t, 62 - i * 4 - j, 33 + i * 4 + j, 60 - 16 * brev(2, i) as i32 + 64 * j as i32, true);
}
}
}
if n >= 3 {
for i in 0..2 {
bf(t, 4 + i, 7 - i, 56 - 32 * i as i32, false);
}
}
if n >= 4 {
for i in 0..4 {
hd(t, 8 + 2 * i, 9 + 2 * i, i & 1 == 1, r);
}
}
if n >= 5 {
for i in 0..2 {
for j in 0..2 {
bf(t, 30 - 4 * i - j, 17 + 4 * i + j, 24 + ((j as i32) << 6) + (((1 - i) as i32) << 5), true);
}
}
}
if n == 6 {
for i in 0..8 {
for j in 0..2 {
hd(t, 32 + i * 4 + j, 35 + i * 4 - j, i & 1 == 1, r);
}
}
}
}
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);
}
if n >= 3 {
for i in 0..2 {
hd(t, 4 + 2 * i, 5 + 2 * i, i == 1, r);
}
}
if n >= 4 {
for i in 0..2 {
bf(t, 14 - i, 9 + i, 48 + 64 * i as i32, true);
}
}
if n >= 5 {
for i in 0..4 {
for j in 0..2 {
hd(t, 16 + 4 * i + j, 19 + 4 * i - j, i & 1 == 1, r);
}
}
}
if n == 6 {
for i in 0..2 {
for j in 0..4 {
bf(t, 61 - i * 8 - j, 34 + i * 8 + j, 56 - i as i32 * 32 + (j as i32 >> 1) * 64, true);
}
}
}
}
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);
}
if n >= 3 {
bf(t, 6, 5, 32, true);
}
if n >= 4 {
for i in 0..2 {
for j in 0..2 {
hd(t, 8 + 4 * i + j, 11 + 4 * i - j, i == 1, r);
}
}
}
if n >= 5 {
for i in 0..4 {
bf(t, 29 - i, 18 + i, 48 + (i as i32 >> 1) * 64, true);
}
}
if n == 6 {
for i in 0..4 {
for j in 0..4 {
hd(t, 32 + 8 * i + j, 39 + 8 * i - j, i & 1 == 1, r);
}
}
}
}
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);
}
}
if n >= 4 {
for i in 0..2 {
bf(t, 13 - i, 10 + i, 32, true);
}
}
if n >= 5 {
for i in 0..2 {
for j in 0..4 {
hd(t, 16 + i * 8 + j, 23 + i * 8 - j, i == 1, r);
}
}
}
if n == 6 {
for i in 0..8 {
bf(t, 59 - i, 36 + i, if i < 4 { 48 } else { 112 }, true);
}
}
if n >= 4 {
for i in 0..8 {
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);
}
}
if n == 6 {
for i in 0..8 {
hd(t, 32 + i, 47 - i, false, r);
hd(t, 48 + i, 63 - i, true, r);
}
}
if n >= 5 {
for i in 0..16 {
hd(t, i, 31 - i, false, r);
}
}
if n == 6 {
for i in 0..8 {
bf(t, 55 - i, 40 + i, 32, true);
}
}
if n == 6 {
for i in 0..32 {
hd(t, i, 63 - i, false, r);
}
}
}
const SINPI_1_9: i64 = 1321;
const SINPI_2_9: i64 = 2482;
const SINPI_3_9: i64 = 3344;
const SINPI_4_9: i64 = 3803;
fn inverse_adst4_scalar(t: &mut [i32]) {
let mut s = [0i64; 7];
let mut x = [0i64; 4];
let t0 = t[0] as i64;
let t1 = t[1] as i64;
let t2 = t[2] as i64;
let t3 = t[3] as i64;
s[0] = SINPI_1_9 * t0;
s[1] = SINPI_2_9 * t0;
s[2] = SINPI_3_9 * t1;
s[3] = SINPI_4_9 * t2;
s[4] = SINPI_1_9 * t2;
s[5] = SINPI_2_9 * t3;
s[6] = SINPI_4_9 * t3;
let a7 = t0 - t2;
let b7 = a7 + t3;
s[0] += s[3];
s[1] -= s[4];
s[3] = s[2];
s[2] = SINPI_3_9 * b7;
s[0] += s[5];
s[1] -= s[6];
x[0] = s[0] + s[3];
x[1] = s[1] + s[3];
x[2] = s[2];
x[3] = s[0] + s[1];
x[3] -= s[3];
for i in 0..4 {
t[i] = round2_64(x[i], 12);
}
}
fn adst_in_permute<T: Lane>(t: &mut [T], n: u32) {
let n0 = 1usize << n;
let mut copy = [T::ZERO; 16];
copy[..n0].copy_from_slice(&t[..n0]);
for i in 0..n0 {
let idx = if i & 1 == 1 { i - 1 } else { n0 - i - 1 };
t[i] = copy[idx];
}
}
fn adst_out_permute<T: Lane>(t: &mut [T], n: u32) {
let n0 = 1usize << n;
let mut copy = [T::ZERO; 16];
copy[..n0].copy_from_slice(&t[..n0]);
for i in 0..n0 {
let a = (i >> 3) & 1;
let b = ((i >> 2) & 1) ^ ((i >> 3) & 1);
let c = ((i >> 1) & 1) ^ ((i >> 2) & 1);
let d = (i & 1) ^ ((i >> 1) & 1);
let idx = ((d << 3) | (c << 2) | (b << 1) | a) >> (4 - n);
t[i] = if i & 1 == 1 { copy[idx].neg() } else { copy[idx] };
}
}
fn inverse_adst8<T: Lane>(t: &mut [T], r: u32) {
adst_in_permute(t, 3);
for i in 0..4 {
bf(t, 2 * i, 2 * i + 1, 60 - 16 * i as i32, true);
}
for i in 0..4 {
hd(t, i, 4 + i, false, r);
}
for i in 0..2 {
bf(t, 4 + 3 * i, 5 + i, 48 - 32 * i as i32, true);
}
for i in 0..2 {
for j in 0..2 {
hd(t, 4 * j + i, 2 + 4 * j + i, false, r);
}
}
for i in 0..2 {
bf(t, 2 + 4 * i, 3 + 4 * i, 32, true);
}
adst_out_permute(t, 3);
}
fn inverse_adst16<T: Lane>(t: &mut [T], r: u32) {
adst_in_permute(t, 4);
for i in 0..8 {
bf(t, 2 * i, 2 * i + 1, 62 - 8 * i as i32, true);
}
for i in 0..8 {
hd(t, i, 8 + i, false, r);
}
for i in 0..2 {
bf(t, 8 + 2 * i, 9 + 2 * i, 56 - 32 * i as i32, true);
bf(t, 13 + 2 * i, 12 + 2 * i, 8 + 32 * i as i32, true);
}
for i in 0..4 {
for j in 0..2 {
hd(t, 8 * j + i, 4 + 8 * j + i, false, r);
}
}
for i in 0..2 {
for j in 0..2 {
bf(t, 4 + 8 * j + 3 * i, 5 + 8 * j + i, 48 - 32 * i as i32, true);
}
}
for i in 0..2 {
for j in 0..4 {
hd(t, 4 * j + i, 2 + 4 * j + i, false, r);
}
}
for i in 0..4 {
bf(t, 2 + 4 * i, 3 + 4 * i, 32, true);
}
adst_out_permute(t, 4);
}
fn inverse_adst<T: Lane>(t: &mut [T], n: u32, r: u32) {
match n {
2 => T::adst4(t),
3 => inverse_adst8(t, r),
_ => inverse_adst16(t, r),
}
}
fn inverse_identity<T: Lane>(t: &mut [T], n: u32) {
match n {
2 => {
for v in t[..4].iter_mut() {
*v = v.mul_round12(5793);
}
}
3 => {
for v in t[..8].iter_mut() {
*v = v.mul(2);
}
}
4 => {
for v in t[..16].iter_mut() {
*v = v.mul_round12(11586);
}
}
_ => {
for v in t[..32].iter_mut() {
*v = v.mul(4);
}
}
}
}
fn inverse_wht(t: &mut [i32], shift: u32) {
let mut a = t[0] >> shift;
let mut c = t[1] >> shift;
let mut d = t[2] >> shift;
let mut b = t[3] >> shift;
a += c;
d -= b;
let e = (a - d) >> 1;
b = e - b;
c = e - c;
a -= b;
d += c;
t[0] = a;
t[1] = b;
t[2] = c;
t[3] = d;
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind1d {
Dct,
Adst,
Identity,
}
fn row_kind(tx_type: usize) -> Kind1d {
match tx_type {
DCT_DCT | ADST_DCT | FLIPADST_DCT | H_DCT => Kind1d::Dct,
DCT_ADST | ADST_ADST | DCT_FLIPADST | FLIPADST_FLIPADST | ADST_FLIPADST | FLIPADST_ADST | H_ADST | H_FLIPADST => Kind1d::Adst,
_ => Kind1d::Identity,
}
}
fn col_kind(tx_type: usize) -> Kind1d {
match tx_type {
DCT_DCT | DCT_ADST | DCT_FLIPADST | V_DCT => Kind1d::Dct,
ADST_DCT | ADST_ADST | FLIPADST_DCT | FLIPADST_FLIPADST | ADST_FLIPADST | FLIPADST_ADST | V_ADST | V_FLIPADST => Kind1d::Adst,
_ => Kind1d::Identity,
}
}
/// 2D inverse transform (7.13.3). `dequant` is 64x64 (row stride 64, only the top-left 32x32
/// may be non-zero); the residual is written to `residual` with row stride `w`.
pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, lossless: bool, bit_depth: u32) {
let log2w = TX_WIDTH_LOG2[tx_sz] as u32;
let log2h = TX_HEIGHT_LOG2[tx_sz] as u32;
let w = 1usize << log2w;
let h = 1usize << log2h;
let row_shift = if lossless { 0 } else { TRANSFORM_ROW_SHIFT[tx_sz] as u32 };
let col_shift = if lossless { 0 } else { 4 };
let row_clamp = bit_depth + 8;
let col_clamp = (bit_depth + 6).max(16);
let rk = row_kind(tx_type);
let ck = col_kind(tx_type);
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 {
if w >= 8 && h >= 8 {
transform_lanes::<8>(dequant, residual, &p);
} else {
transform_lanes::<4>(dequant, residual, &p);
}
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
// Round2( 0 ) = 0), so rows without coefficients (including rows 32..63) are zero.
if i >= 32 || dequant[i * 64..i * 64 + in_w].iter().all(|&v| v == 0) {
residual[i * w..i * w + w].fill(0);
continue;
}
t[..in_w].copy_from_slice(&dequant[i * 64..i * 64 + in_w]);
t[in_w..w].fill(0);
if rect2 {
for v in t[..w].iter_mut() {
*v = round2_64(*v as i64 * 2896, 12);
}
}
if lossless {
inverse_wht(&mut t, 2);
} else {
match rk {
Kind1d::Dct => inverse_dct(&mut t, log2w, row_clamp),
Kind1d::Adst => inverse_adst(&mut t, log2w, row_clamp),
Kind1d::Identity => inverse_identity(&mut t, log2w),
}
}
let lo = -(1i32 << (col_clamp - 1));
let hi = (1i32 << (col_clamp - 1)) - 1;
for j in 0..w {
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];
}
if lossless {
inverse_wht(&mut t, 0);
} else {
match ck {
Kind1d::Dct => inverse_dct(&mut t, log2h, col_clamp),
Kind1d::Adst => inverse_adst(&mut t, log2h, col_clamp),
Kind1d::Identity => inverse_identity(&mut t, log2h),
}
}
for i in 0..h {
residual[i * w + j] = round2(t[i], col_shift);
}
}
}
struct Tx2d {
w: usize,
h: usize,
log2w: u32,
log2h: u32,
row_shift: u32,
col_shift: u32,
row_clamp: u32,
col_clamp: u32,
rk: Kind1d,
ck: Kind1d,
rect2: bool,
}
#[inline(always)]
fn transform_1d<T: Lane>(t: &mut [T], kind: Kind1d, n: u32, r: u32) {
match kind {
Kind1d::Dct => inverse_dct(t, n, r),
Kind1d::Adst => inverse_adst(t, n, r),
Kind1d::Identity => inverse_identity(t, n),
}
}
/// 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 i0 = 0;
while i0 < h {
// Rows without coefficients (and rows 32..63) transform to zeros.
if i0 >= 32 || (i0..i0 + L).all(|i| dequant[i * 64..i * 64 + in_w].iter().all(|&v| v == 0)) {
residual[i0 * w..(i0 + L) * w].fill(0);
i0 += L;
continue;
}
for (j, tj) in t[..w].iter_mut().enumerate() {
for l in 0..L {
tj[l] = if j < in_w { dequant[(i0 + l) * 64 + j] } else { 0 };
}
}
if p.rect2 {
for v in t[..w].iter_mut() {
*v = v.mul_round12(2896);
}
}
transform_1d(&mut t[..], p.rk, p.log2w, p.row_clamp);
for l in 0..L {
let out = &mut residual[(i0 + l) * w..(i0 + l + 1) * w];
for j in 0..w {
out[j] = round2(t[j][l], p.row_shift).clamp(lo, hi);
}
}
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 {
t[i].copy_from_slice(&residual[i * w + j0..i * w + j0 + L]);
}
transform_1d(&mut t[..], p.ck, p.log2h, p.col_clamp);
for i in 0..h {
let out = &mut residual[i * w + j0..i * w + j0 + L];
for l in 0..L {
out[l] = round2(t[i][l], p.col_shift);
}
}
j0 += L;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The DCT step list must implement an orthogonal transform: a DC-only input gives a flat
/// output equal to DC * cos(pi/4) (in the 2^12 fixed-point scale).
#[test]
fn dct_dc_is_flat() {
for n in 2..=6 {
let len = 1 << n;
let mut t = [0i32; 64];
t[0] = 4096;
inverse_dct(&mut t, n, 24);
for v in &t[..len] {
assert_eq!(*v, 2896, "n={n}: {:?}", &t[..len]);
}
}
}
/// Compare each inverse DCT basis vector with the real-valued DCT-III.
#[test]
fn dct_matches_float() {
for n in 2..=6u32 {
let len = 1usize << n;
for k in 0..len {
let mut t = [0i32; 64];
t[k] = 1 << 12;
inverse_dct(&mut t, n, 24);
for x in 0..len {
let c = if k == 0 { std::f64::consts::FRAC_1_SQRT_2 } else { 1.0 };
let want = 4096.0 * c * ((2 * x + 1) as f64 * k as f64 * std::f64::consts::PI / (2 * len) as f64).cos();
assert!((t[x] as f64 - want).abs() < 4.0 + 0.5 * n as f64, "n={n} k={k} x={x}: {} vs {want}", t[x]);
}
}
}
}
/// ADST8/16 basis vectors follow sin(pi (2i+1)(2k+1) / 4N) (scaled like the DCT).
#[test]
fn adst_basis_matches_sine() {
for n in 3..=4u32 {
let len = 1usize << n;
for k in 0..len {
let mut t = [0i32; 64];
t[k] = 1 << 12;
inverse_adst(&mut t, n, 24);
for i in 0..len {
let want = 4096.0 * ((std::f64::consts::PI * (2 * i + 1) as f64 * (2 * k + 1) as f64) / (4 * len) as f64).sin();
assert!((t[i] as f64 - want).abs() < 8.0, "n={n} k={k} i={i}: {} vs {want:.1}", t[i]);
}
}
}
}
#[test]
fn adst_is_close_to_sine_transform() {
for n in 3..=4u32 {
let len = 1usize << n;
for k in 0..len {
let mut t = [0i32; 64];
t[k] = 1 << 12;
inverse_adst(&mut t, n, 24);
let energy: f64 = t[..len].iter().map(|&v| (v as f64 / 4096.0).powi(2)).sum();
// like the DCT, the AV1 ADST is scaled by sqrt(N / 2)
assert!((energy - len as f64 / 2.0).abs() < 0.02, "n={n} k={k} energy {energy}");
}
}
}
}
-12
View File
@@ -1,12 +0,0 @@
[package]
name = "deckcraft-bitstream"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
craft-media = { workspace = true, features = ["bitstream"] }
-82
View File
@@ -1,82 +0,0 @@
//! Memory budget for decoded pictures, shared by the video decoders.
//!
//! A per-axis size cap does not bound memory: a decoder holds the current picture, every
//! reference picture and the pictures still being decoded on worker threads. Each decoder checks
//! the whole picture store against [`DECODE_MEMORY_BUDGET`] with checked arithmetic before it
//! allocates for a new picture size, so a hostile header is an error, not an out-of-memory abort.
/// Bytes the decoded pictures of one decoder may take: 4 GiB on 64-bit targets, 1 GiB on 32-bit
/// ones (wasm32 has a 4 GiB address space in all).
pub const DECODE_MEMORY_BUDGET: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 };
/// The shape of one decoded picture as a decoder allocates it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PictureShape {
/// Allocated luma samples per row and rows (padding included).
pub width: u64,
pub height: u64,
/// Chroma subsampling shifts (0 or 1); ignored when `chroma_planes` is 0.
pub shift_x: u32,
pub shift_y: u32,
/// Chroma planes (0 for monochrome, 2 otherwise).
pub chroma_planes: u64,
/// Bytes per sample (1 for 8-bit buffers, 2 for 16-bit ones).
pub bytes_per_sample: u64,
}
impl PictureShape {
/// Bytes of one picture, `None` if it does not fit in a `u64`.
pub fn bytes(&self) -> Option<u64> {
let luma = self.width.checked_mul(self.height)?;
let cw = self.width.div_ceil(1 << self.shift_x.min(1));
let ch = self.height.div_ceil(1 << self.shift_y.min(1));
let chroma = cw.checked_mul(ch)?.checked_mul(self.chroma_planes)?;
luma.checked_add(chroma)?.checked_mul(self.bytes_per_sample)
}
/// Bytes of `pictures` such pictures, `None` on overflow.
pub fn store_bytes(&self, pictures: u64) -> Option<u64> {
self.bytes()?.checked_mul(pictures)
}
/// Whether `pictures` such pictures fit in `budget` bytes.
pub fn fits(&self, pictures: u64, budget: u64) -> bool {
self.store_bytes(pictures).is_some_and(|b| b <= budget)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn shape(w: u64, h: u64) -> PictureShape {
PictureShape { width: w, height: h, shift_x: 1, shift_y: 1, chroma_planes: 2, bytes_per_sample: 2 }
}
#[test]
fn bytes_count_luma_and_subsampled_chroma() {
assert_eq!(shape(4, 4).bytes(), Some((16 + 2 * 4) * 2));
// Odd sizes round chroma up.
assert_eq!(shape(3, 3).bytes(), Some((9 + 2 * 4) * 2));
let mono = PictureShape { chroma_planes: 0, bytes_per_sample: 1, ..shape(10, 10) };
assert_eq!(mono.bytes(), Some(100));
let full = PictureShape { shift_x: 0, shift_y: 0, ..shape(10, 10) };
assert_eq!(full.bytes(), Some(600));
}
#[test]
fn exact_budget_boundary() {
let s = shape(4, 4); // 48 bytes
assert!(s.fits(10, 480));
assert!(!s.fits(10, 479));
assert!(!s.fits(11, 480));
}
#[test]
fn overflow_is_never_within_budget() {
let huge = shape(u64::MAX / 2, 3);
assert_eq!(huge.bytes(), None);
assert!(!huge.fits(1, u64::MAX));
assert!(!shape(1 << 31, 1 << 31).fits(u64::MAX, u64::MAX));
}
}
-14
View File
@@ -1,14 +0,0 @@
//! Bit-level reading and writing for DeckCraft's codec crates: a re-export of
//! [`craft_media::bitstream`] from craft-libs, plus [`budget`].
//!
//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers.
//! - [`BitWriter`]: the matching writer (for encoders and header rewriting).
//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes.
//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
//! - [`budget`]: the memory budget video decoders check their picture store against.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub use craft_media::bitstream::*;
pub mod budget;
+11 -7
View File
@@ -466,22 +466,26 @@ fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape:
/// Run `f` on each target shape (copy-on-write), materialising inherited boxes first.
pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
let ids = targets(s, p)?;
let mut ids = targets(s, p)?;
if ids.is_empty() {
return Err(bad(cmd, "no shapes selected"));
}
// A shape named twice is edited once.
let mut seen = std::collections::HashSet::new();
ids.retain(|id| seen.insert(*id));
s.edit(|doc, sel| {
let boxes: Vec<(ShapeId, Xfrm)> = {
let st_shapes: Vec<deckcraft_model::Shape> =
ids.iter().filter_map(|id| current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, *id)).cloned()).collect();
st_shapes.iter().map(|sh| (sh.id, xfrm_of(doc, sel, sh))).collect()
};
// The boxes are read from the shapes in place: no copy of a shape (and its text) per id.
let boxes: std::collections::HashMap<ShapeId, Xfrm> = ids
.iter()
.filter_map(|id| current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, *id)))
.map(|sh| (sh.id, xfrm_of(doc, sel, sh)))
.collect();
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?;
let mut n = 0;
for id in &ids {
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
if sh.xfrm.is_none() {
sh.xfrm = boxes.iter().find(|(i, _)| i == id).map(|(_, x)| *x);
sh.xfrm = boxes.get(id).copied();
}
f(sh)?;
n += 1;
+188 -9
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Animation Styles",
["Animations", "Animation"],
None,
"{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, ids?} — replaces the shapes' animations",
"{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, path?: [[x,y],…] or OOXML string, closed?: bool, ids?} — replaces the shapes' animations. path (motion paths): points in slide points, as offsets from where the shape sits, or the OOXML path string (slide fractions) animation.get answers",
has_selection,
set
),
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Animation",
["Animations", "Advanced Animation"],
None,
"{effect, class?, option?, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?}",
"{effect, class?, option?, path?: [[x,y],…] or OOXML string, closed?: bool, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?} → {count, path?}",
has_selection,
add
),
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effect Options",
["Animations", "Animation"],
None,
"{option?, textBuild?: asOne|byParagraph|byWord|byLetter, index?}",
"{option?, textBuild?: asOne|byParagraph|byWord|byLetter, path?: [[x,y],…] or OOXML string, closed?: bool, index?}",
has_slide,
options
),
@@ -129,7 +129,7 @@ fn resolve_option(allowed: &[&str], given: &str) -> Option<String> {
cands.into_iter().find(|c| allowed.contains(&c.as_str()))
}
fn build(p: &Value) -> Result<Animation> {
fn build(p: &Value, slide: (f64, f64)) -> Result<Animation> {
let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?;
let class = class_of(str_param(p, "class"));
let info = match class {
@@ -155,7 +155,9 @@ fn build(p: &Value) -> Result<Animation> {
a.start = start_of(st);
}
if a.class == AnimClass::Path {
a.path = Some(default_path(&a.effect, &a.option));
a.path = Some(path_param(p, slide)?.unwrap_or_else(|| default_path(&a.effect, &a.option)));
} else if p.get("path").is_some() {
return Err(bad("animation", "`path` only fits a motion path (class `path`)"));
}
if a.effect == "spin" {
a.amount = Some(360.0);
@@ -166,6 +168,97 @@ fn build(p: &Value) -> Result<Animation> {
Ok(a)
}
/// Most points of a motion path given as an array, and longest path string given as text.
const MAX_PATH_POINTS: usize = 1_000;
const MAX_PATH_TEXT: usize = 16 * 1024;
/// Largest offset of a point, in slide points; anything beyond is not a place on any slide.
const MAX_PATH_OFFSET: f64 = 1.0e6;
/// A number of a path string: slide fractions, rounded to 6 places.
fn path_num(v: f64) -> String {
let t = format!("{:.6}", v);
let t = t.trim_end_matches('0').trim_end_matches('.');
if t == "-0" || t.is_empty() { "0".into() } else { t.into() }
}
/// Does a raw motion path string draw: only commands and numbers, every number finite, and at
/// least two points? (A lone `e`, or `1e999`, would play as an empty or partial path.)
fn raw_path_draws(text: &str) -> bool {
let cs: Vec<char> = text.chars().collect();
let (mut i, mut nums) = (0, 0usize);
while let Some(&c) = cs.get(i) {
if c.is_ascii_digit() || c == '.' || ((c == '-' || c == '+') && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit() || *d == '.')) {
let start = i;
i += 1;
while cs.get(i).is_some_and(|d| d.is_ascii_digit() || *d == '.') {
i += 1;
}
if cs.get(i).is_some_and(|d| *d == 'e' || *d == 'E') {
let n1 = cs.get(i + 1).copied().unwrap_or(' ');
let n2 = cs.get(i + 2).copied().unwrap_or(' ');
if n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit()) {
i += 2;
while cs.get(i).is_some_and(char::is_ascii_digit) {
i += 1;
}
}
}
let tok: String = cs.get(start..i).map(|t| t.iter().collect()).unwrap_or_default();
match tok.parse::<f64>() {
Ok(v) if v.is_finite() => nums += 1,
_ => return false,
}
} else if " ,MmLlCcQqHhVvZzE".contains(c) {
i += 1;
} else {
return false;
}
}
nums >= 4
}
/// The motion path `path` asks for, as the OOXML path string animations store: either points
/// (`[[x, y], …]`, slide points as offsets from where the shape sits, joined by lines, `closed?`
/// to return to the first) or that string itself.
fn path_param(p: &Value, slide: (f64, f64)) -> Result<Option<String>> {
let Some(v) = p.get("path") else { return Ok(None) };
let refuse = |why: &str| bad("animation", format!("`path` {why}"));
match v {
Value::String(text) => {
let ok = text.len() <= MAX_PATH_TEXT
&& text.contains(|c: char| c.is_ascii_digit())
&& text.chars().all(|c| c.is_ascii_digit() || " .+-eE,MmLlCcQqHhVvZz".contains(c))
&& raw_path_draws(text);
if !ok {
return Err(refuse("string must be an OOXML motion path such as `M 0 0 L 0.25 0 E` (at most 16 KiB)"));
}
Ok(Some(text.clone()))
}
Value::Array(points) => {
if !(2..=MAX_PATH_POINTS).contains(&points.len()) {
return Err(refuse(&format!("needs 2 to {MAX_PATH_POINTS} points")));
}
if !(slide.0.is_finite() && slide.1.is_finite() && slide.0 > 0.0 && slide.1 > 0.0) {
return Err(refuse("can't be placed: the slide has no size"));
}
let mut out = String::new();
for (k, pt) in points.iter().enumerate() {
let xy = pt.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
let Some((x, y)) = xy.filter(|(x, y)| x.abs() <= MAX_PATH_OFFSET && y.abs() <= MAX_PATH_OFFSET) else {
return Err(refuse(&format!("point {k} must be [x, y], two finite numbers within ±{MAX_PATH_OFFSET} points")));
};
out.push_str(&format!("{} {} {} ", if k == 0 { "M" } else { "L" }, path_num(x / slide.0), path_num(y / slide.1)));
}
if p.get("closed").and_then(Value::as_bool) == Some(true) {
out.push_str("Z ");
}
out.push('E');
Ok(Some(out))
}
_ => Err(refuse("must be [[x, y], …] or an OOXML path string")),
}
}
/// Default motion paths, in slide-fraction units relative to the shape's position.
pub fn default_path(effect: &str, option: &str) -> String {
let (dx, dy) = match option {
@@ -185,7 +278,7 @@ pub fn default_path(effect: &str, option: &str) -> String {
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let a = build(p)?;
let a = build(p, slide_extent(s)?)?;
s.edit(|doc, sel| {
let sl = doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.set", "no slide"))?;
let sl = Arc::make_mut(sl);
@@ -215,9 +308,16 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// The slide's width and height in points.
fn slide_extent(s: &mut Session) -> Result<(f64, f64)> {
let size = &s.doc()?.doc.slide_size;
Ok((size.width, size.height))
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let a = build(p)?;
let a = build(p, slide_extent(s)?)?;
let path = a.path.clone();
s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.add", "no slide"))?);
for (k, id) in ids.iter().enumerate() {
@@ -227,7 +327,10 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
sl.animations.push(x);
}
Ok(json!({"count": sl.animations.len()}))
Ok(match &path {
Some(path) => json!({"count": sl.animations.len(), "path": path}),
None => json!({"count": sl.animations.len()}),
})
})
}
@@ -320,13 +423,17 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
"byLetter" => TextBuild::ByLetter,
_ => TextBuild::AsOne,
});
let path = path_param(p, slide_extent(s)?)?;
each_target(s, p, |a| {
if let (Some(path), AnimClass::Path) = (&path, a.class) {
a.path = Some(path.clone());
}
if let Some(o) = &option {
// Each target takes the option in its own effect's terms; one it has no such
// option for keeps its own.
let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]);
if let Some(o) = resolve_option(allowed, o) {
if a.class == AnimClass::Path {
if a.class == AnimClass::Path && path.is_none() {
a.path = Some(default_path(&a.effect, &o));
}
a.option = o;
@@ -606,4 +713,76 @@ mod tests {
assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1);
}
}
fn path_of(s: &mut Session) -> Option<String> {
let all = s.execute("animation.get", &json!({})).unwrap();
all.as_array().unwrap().last().and_then(|a| a.get("path")).and_then(Value::as_str).map(String::from)
}
fn shape(s: &mut Session) -> u64 {
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100.0, 400.0, 20.0, 20.0]})).unwrap().get("id").unwrap().as_u64().unwrap()
}
/// #1576: points in slide points, as offsets from the shape, become the slide-fraction path.
#[test]
fn a_custom_path_takes_its_points() {
let mut s = Session::with_new();
let id = shape(&mut s);
let w = s.doc().unwrap().doc.slide_size.width;
let h = s.doc().unwrap().doc.slide_size.height;
let r = s
.execute(
"animation.add",
&json!({"effect": "customPath", "class": "path", "path": [[0, 0], [w / 2.0, -h / 4.0], [w, -h / 2.0]], "ids": [id]}),
)
.unwrap();
assert_eq!(r["path"], "M 0 0 L 0.5 -0.25 L 1 -0.5 E");
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.5 -0.25 L 1 -0.5 E"));
// closed shapes end back at the start; set replaces; no path keeps the default
s.execute("animation.set", &json!({"effect": "customPath", "path": [[0, 0], [w / 4.0, 0], [w / 4.0, h / 4.0]], "closed": true, "ids": [id]}))
.unwrap();
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.25 0 L 0.25 0.25 Z E"));
s.execute("animation.set", &json!({"effect": "customPath", "ids": [id]})).unwrap();
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0 0.25 E"));
}
#[test]
fn a_raw_path_string_is_kept_and_options_edits_it() {
let mut s = Session::with_new();
let id = shape(&mut s);
s.execute("animation.add", &json!({"effect": "customPath", "path": "M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E", "ids": [id]})).unwrap();
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E"));
s.execute("animation.options", &json!({"index": 0, "path": [[0, 0], [96, 54]]})).unwrap();
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.1 0.1 E"));
}
#[test]
fn a_bad_path_is_refused_and_changes_nothing() {
let mut s = Session::with_new();
let id = shape(&mut s);
let many: Vec<Value> = (0..2_000).map(|i| json!([i, i])).collect();
for bad_path in [
json!([[0, 0]]),
json!([]),
json!([[0, 0], [1]]),
json!([[0, 0], ["a", 1]]),
json!([[0, 0], [1e300, 1]]),
json!([[0, 0], [null, 1]]),
json!("M 0 0 L <script> E"),
json!("not a path"),
json!("eeee1"),
json!("M 0 0 L 1e999 0 E"),
json!(5),
Value::Array(many),
Value::String("M 0 0 L 0.1 0.1 ".repeat(10_000)),
] {
let r = s.execute("animation.add", &json!({"effect": "customPath", "path": bad_path, "ids": [id]}));
assert!(r.is_err(), "{bad_path:.60}");
}
assert!(
s.execute("animation.add", &json!({"effect": "fade", "path": [[0, 0], [1, 1]], "ids": [id]})).is_err(),
"path only fits a motion path"
);
assert!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().is_empty(), "a refused add left an animation behind");
}
}
+131 -9
View File
@@ -116,16 +116,31 @@ fn pack(data: &[u8], crop: [f64; 4], w_pt: f64, h_pt: f64, ppi: f64, delete_crop
Some(Packed { bytes, content_type, crop: new_crop })
}
/// Picture shapes in `shapes` (groups included) that `want` accepts.
fn pictures_mut<'a>(shapes: &'a mut [Shape], want: &dyn Fn(ShapeId) -> bool, out: &mut Vec<&'a mut Shape>, depth: usize) {
/// Pictures accepted by `want`, with width and height measured in slide space through group transforms.
fn pictures_mut<'a>(
shapes: &'a mut [Shape],
want: &dyn Fn(ShapeId) -> bool,
out: &mut Vec<(&'a mut Shape, f64, f64)>,
to_slide: deckcraft_geom::Affine,
depth: usize,
) {
if depth > 64 {
return;
}
for sh in shapes {
if matches!(sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
out.push(sh);
} else if let Some(ch) = sh.children_mut() {
pictures_mut(ch, want, out, depth + 1);
let Some(xfrm) = sh.xfrm else { continue };
let local_to_slide = to_slide * xfrm.affine();
if matches!(&sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
let origin = local_to_slide * deckcraft_geom::Point::ZERO;
let across = local_to_slide * deckcraft_geom::Point::new(xfrm.w, 0.0);
let down = local_to_slide * deckcraft_geom::Point::new(0.0, xfrm.h);
out.push((sh, (across - origin).hypot(), (down - origin).hypot()));
continue;
}
if let ShapeKind::Group { children, child } = &mut sh.kind {
let child_rect = deckcraft_geom::Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
let child_to_slide = to_slide * deckcraft_geom::group_child_affine(&xfrm, child_rect);
pictures_mut(children, want, out, child_to_slide, depth + 1);
}
}
}
@@ -155,10 +170,9 @@ fn compress(s: &mut Session, p: &Value) -> Result<Value> {
let Some(slide) = doc.slides.get(i).cloned() else { continue };
let mut slide = slide.as_ref().clone();
let mut pics = vec![];
pictures_mut(&mut slide.shapes, &want, &mut pics, 0);
pictures_mut(&mut slide.shapes, &want, &mut pics, deckcraft_geom::Affine::IDENTITY, 0);
let mut touched = false;
for sh in pics {
let (w, h) = sh.xfrm.map(|x| (x.w, x.h)).unwrap_or((0.0, 0.0));
for (sh, w, h) in pics {
let ShapeKind::Picture { fill } = &mut sh.kind else { continue };
let PictureFill { media, crop, .. } = fill.clone();
let key = (media.0, crop.map(f64::to_bits), w.to_bits(), h.to_bits());
@@ -232,6 +246,114 @@ mod tests {
assert!(pack(b"<svg xmlns='http://www.w3.org/2000/svg'/>", [0.0; 4], 72.0, 72.0, 150.0, true).is_none());
}
#[test]
fn grouped_pictures_use_their_displayed_size() {
let mut s = Session::with_new();
let original = png(600, 600);
s.edit(|doc, _| {
let media = doc.add_media("grouped.png", "image/png", original.clone()).ok_or_else(|| bad("test", "media"))?;
let picture = Shape {
id: ShapeId(70_101),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
..Default::default()
};
let nested = Shape {
id: ShapeId(70_102),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
..Default::default()
};
let group = Shape {
id: ShapeId(70_103),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 720.0)),
kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
..Default::default()
};
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
Arc::make_mut(slide).shapes.push(group);
Ok(())
})
.unwrap();
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
assert_eq!(result["pictures"], 0, "a 600 px image displayed at 720×720 pt is already below the requested sampling density");
assert!(
s.doc().unwrap().doc.media.iter().any(|m| m.data.as_slice() == original.as_slice()),
"the under-target original media must remain untouched"
);
}
#[test]
fn anisotropic_nested_group_scaling_controls_sampling_dimensions() {
let mut s = Session::with_new();
let original = png(2000, 1000);
s.edit(|doc, _| {
let media = doc.add_media("scaled-group.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
let picture = Shape {
id: ShapeId(70_111),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
..Default::default()
};
let nested = Shape {
id: ShapeId(70_112),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
..Default::default()
};
let group = Shape {
id: ShapeId(70_113),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 360.0)),
kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
..Default::default()
};
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
Arc::make_mut(slide).shapes.push(group);
Ok(())
})
.unwrap();
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
assert_eq!(result["pictures"], 1);
let slide = s.doc().unwrap().doc.slides.first().unwrap();
let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_113)).unwrap();
let ShapeKind::Group { children, .. } = &picture.kind else { panic!("outer group expected") };
let ShapeKind::Group { children, .. } = &children[0].kind else { panic!("nested group expected") };
let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture expected") };
let media = s.doc().unwrap().doc.media(fill.media).unwrap();
let image = image::load_from_memory(&media.data).unwrap();
assert_eq!((image.width(), image.height()), (960, 480));
}
#[test]
fn ungrouped_picture_still_compresses_and_deletes_its_crop() {
let mut s = Session::with_new();
let original = png(800, 800);
s.edit(|doc, _| {
let media = doc.add_media("cropped.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
Arc::make_mut(slide).shapes.push(Shape {
id: ShapeId(70_104),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Picture { fill: PictureFill { media, crop: [0.5, 0.0, 0.0, 0.0], ..Default::default() } },
..Default::default()
});
Ok(())
})
.unwrap();
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": true})).unwrap();
assert_eq!(result["pictures"], 1);
let slide = s.doc().unwrap().doc.slides.first().unwrap();
let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_104)).unwrap();
let ShapeKind::Picture { fill } = &picture.kind else { panic!("picture expected") };
assert_eq!(fill.crop, [0.0; 4]);
let media = s.doc().unwrap().doc.media(fill.media).unwrap();
let image = image::load_from_memory(&media.data).unwrap();
assert_eq!((image.width(), image.height()), (96, 192));
}
#[test]
fn pack_deletes_cropped_areas_or_keeps_them() {
let src = png(400, 400);
+3 -3
View File
@@ -1,17 +1,17 @@
use super::{
Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json,
master_index, ok, opt_usize_param, rect_param, str_param, usize_param,
master_index, ok, opt_usize_param, rect_param, str_param,
};
pub(super) fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection));
let mi = opt_usize_param(p, "master", "view.slideMaster")?.unwrap_or_else(|| master_index(&st.doc, &st.selection));
// A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects
// that layout. With neither key, follow the current slide's layout.
let layout = if p.get("master").is_some() && p.get("layout").is_none() {
None
} else {
usize_param(p, "layout")
opt_usize_param(p, "layout", "view.slideMaster")?
.or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout))))
};
let target = match layout {
@@ -1,12 +1,14 @@
use super::{
Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value,
f64_param, ok, usize_param, with_param,
f64_param, ok, opt_usize_param, usize_param, with_param,
};
pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
if bool_or(p, "copy", false) {
return copy_slide_background(s);
}
// Check the index before anything is edited: a picture is added to the media first.
let index = opt_usize_param(p, "index", "design.background")?;
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
let transparency = f64_param(p, "transparency");
let reset = bool_or(p, "reset", false);
@@ -62,7 +64,6 @@ pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
));
};
let all = bool_or(p, "all", false);
let index = usize_param(p, "index");
if matches!(op, BgOp::Adjust) {
let next = {
let st = s.doc()?;
+13
View File
@@ -485,3 +485,16 @@ fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
}
#[test]
fn background_rejects_invalid_index() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({})).unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
let before = s.doc().unwrap().doc.slides[0].background.clone();
for bad in [json!(-1), json!(1.5)] {
assert!(s.execute("design.background", &json!({"color": "#0000C8", "index": bad})).is_err());
assert!(s.execute("design.background", &json!({"transparency": 0.5, "index": bad})).is_err());
assert_eq!(s.doc().unwrap().doc.slides[0].background, before);
}
}
+45 -16
View File
@@ -107,7 +107,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() };
return Ok(plain);
}
let shapes: Vec<Shape> = st
let mut shapes: Vec<Shape> = st
.selection
.shapes
.iter()
@@ -120,14 +120,33 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
})
})
.collect();
// Copying is the last chance to read what a placeholder inherits: a cut removes it, and the
// paste can be in another deck. Slide Master view pastes the originals instead.
let placeholders: Vec<Shape> = shapes.iter().filter(|x| x.ph.is_some()).cloned().collect();
detach_on_slide(&st.doc, &st.selection, &mut shapes);
let mids = crate::media_refs::shapes_media(&shapes);
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n");
let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() };
// The theme of what is being edited: a slide's or layout's colour map, else the master's.
let source_theme = super::text::ctx_for(&st.doc, &st.selection).map(|ctx| crate::SourceTheme {
colors: ctx.master.theme.colors.clone(),
color_map: ctx.color_map.clone(),
fonts: ctx.master.theme.fonts.clone(),
});
s.clipboard = crate::Clipboard { shapes, placeholders, media, plain: plain.clone(), source_theme, ..Default::default() };
Ok(plain)
}
/// On a slide, a copy of a placeholder is a regular shape: give `shapes` the look they inherit
/// there. In Slide Master view a copy stays a placeholder, so this changes nothing.
pub(crate) fn detach_on_slide(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shapes: &mut [Shape]) {
if sel.target == crate::Target::Slides
&& let Some(ctx) = super::text::ctx_for(doc, sel)
{
shapes.iter_mut().for_each(|sh| deckcraft_model::resolve::detach_placeholder(&ctx, sh));
}
}
fn copying_caret(s: &Session) -> bool {
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
}
@@ -319,6 +338,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
return s.edit(|doc, sel| {
let media = bring_media("edit.paste", doc, &clip.media)?;
let mut shapes = clip.shapes.clone();
if sel.target != crate::Target::Slides {
for sh in &mut shapes {
if let Some(original) = clip.placeholders.iter().find(|p| p.id == sh.id) {
sh.clone_from(original);
}
}
}
reid("edit.paste", doc, &mut shapes)?;
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
let (mut taken, mut count) = super::current_names(doc, sel);
@@ -340,7 +366,8 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
x.x += off;
x.y += off;
}
// A pasted placeholder becomes a regular shape keeping its look.
// A placeholder pasted on a slide becomes a regular shape. One copied from a slide
// already has its look; see `copy_inner`.
if sh.ph.is_some() && sel.target == crate::Target::Slides {
sh.ph = None;
}
@@ -404,8 +431,9 @@ fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
"text" | "keepTextOnly" => paste_text(s, &json!({})),
"keepSource" | "keepSourceFormatting" => {
let saved = s.clipboard.clone();
if let Some((scheme, fonts)) = &saved.source_theme {
bake_theme(&mut s.clipboard.shapes, scheme, fonts);
if let Some(theme) = &saved.source_theme {
bake_theme(&mut s.clipboard.shapes, theme);
bake_theme(&mut s.clipboard.placeholders, theme);
}
let r = paste(s, &json!({}));
let pastes = s.clipboard.pastes;
@@ -420,8 +448,8 @@ fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
/// Replace theme colours and theme fonts with the values they have in `scheme` / `fonts`, so the
/// shapes keep their look under another theme.
pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme) {
fn rec(v: &mut Value, scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme, depth: usize) {
pub(crate) fn bake_theme(shapes: &mut [Shape], theme: &crate::SourceTheme) {
fn rec(v: &mut Value, theme: &crate::SourceTheme, depth: usize) {
if depth > 256 {
return;
}
@@ -430,7 +458,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
if o.get("kind").and_then(Value::as_str) == Some("scheme")
&& let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::<deckcraft_color::SchemeSlot>(x.clone()).ok())
&& slot != deckcraft_color::SchemeSlot::PhClr
&& let Ok(rgb) = serde_json::to_value(scheme.get(slot))
&& let Ok(rgb) = serde_json::to_value(theme.colors.get(deckcraft_model::map_slot(&theme.color_map, slot)))
{
o.clear();
o.insert("kind".into(), json!("rgb"));
@@ -438,14 +466,14 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
return;
}
for x in o.values_mut() {
rec(x, scheme, fonts, depth + 1);
rec(x, theme, depth + 1);
}
}
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, scheme, fonts, depth + 1)),
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, theme, depth + 1)),
Value::String(t) if t.starts_with('+') => {
let set = match t.get(1..3) {
Some("mj") => &fonts.major,
Some("mn") => &fonts.minor,
Some("mj") => &theme.fonts.major,
Some("mn") => &theme.fonts.minor,
_ => return,
};
let face = match t.get(3..) {
@@ -463,7 +491,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
}
for sh in shapes.iter_mut() {
let Ok(mut v) = serde_json::to_value(&*sh) else { continue };
rec(&mut v, scheme, fonts, 0);
rec(&mut v, theme, 0);
if let Ok(baked) = serde_json::from_value::<Shape>(v) {
*sh = baked;
}
@@ -476,8 +504,8 @@ fn paste_picture(s: &mut Session) -> Result<Value> {
if clip.shapes.is_empty() {
return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture"));
}
if let Some((scheme, fonts)) = &clip.source_theme {
bake_theme(&mut clip.shapes, scheme, fonts);
if let Some(theme) = &clip.source_theme {
bake_theme(&mut clip.shapes, theme);
}
let st = s.doc()?;
let mut doc = (*st.doc).clone();
@@ -533,6 +561,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
let sources: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
detach_on_slide(doc, sel, &mut copies);
// Duplicating the copies of the last Duplicate again repeats how far they were moved
// since (#966); anything else gets the usual offset.
let (dx, dy) = last
+33 -5
View File
@@ -253,8 +253,15 @@ pub struct LoadedFile {
#[cfg(not(target_arch = "wasm32"))]
pub fn read_presentation_file(path: &str) -> Result<LoadedFile> {
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
let (doc, damaged) = open_checked(path, &bytes)?;
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
loaded_file(path, &bytes)
}
/// Parse `bytes` as the presentation file `path`, as [`read_presentation_file`] does after
/// reading it. The web host bridge uses it for a file its page sent, which then saves back to
/// that name like a file opened on the desktop.
pub fn loaded_file(path: &str, bytes: &[u8]) -> Result<LoadedFile> {
let (doc, damaged) = open_checked(path, bytes)?;
let native = deckcraft_format::sniff(bytes) || deckcraft_pptx::sniff(bytes);
Ok(LoadedFile { path: path.to_string(), doc, native, damaged })
}
@@ -345,6 +352,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()),
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
"gif" => gif_bytes(doc, &Value::Null),
"png" | "jpeg" | "jpg" => Err(EngineError::Other("saving a presentation as a single image isn't supported; use file.export".into())),
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
}
}
@@ -359,7 +367,13 @@ pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions>
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
o.layout = match str_param(p, "layout").unwrap_or("slides") {
"notes" => deckcraft_pdf::PageLayout::Notes,
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
"handouts" => {
let per_page = opt_usize_param(p, "perPage", "file.export")?.unwrap_or(6);
if ![1, 2, 3, 4, 6, 9].contains(&per_page) {
return Err(bad("file.export", format!("perPage {per_page} isn't one of 1, 2, 3, 4, 6, 9")));
}
deckcraft_pdf::PageLayout::Handouts { per_page: per_page as u8 }
}
_ => deckcraft_pdf::PageLayout::Slides,
};
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
@@ -390,6 +404,7 @@ pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions>
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
let mut out: Vec<usize> = vec![];
let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
let (a, b) = match part.split_once('-') {
Some((a, b)) => (a.trim(), b.trim()),
@@ -405,7 +420,7 @@ pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
}
for i in lo - 1..hi {
if !out.contains(&i) {
if seen.insert(i) {
out.push(i);
}
}
@@ -462,7 +477,10 @@ pub fn format_for_path(path: &str) -> &'static str {
}
}
fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
/// The active presentation is saved as it is now: no unsaved changes, and `path` (when given)
/// becomes its file. `file.save` calls it after writing; the web host bridge calls it once the
/// page has the bytes.
pub fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
let st = s.doc_mut()?;
st.saved_doc = st.doc.clone();
if path.is_some() {
@@ -769,6 +787,16 @@ mod tests {
use super::{parse_slide_range, write_file};
#[test]
fn broad_and_repeated_slide_ranges_expand_once() {
// 200 copies of a 100,000-slide range: each index once, in order, without a scan per index.
let text = vec!["1-100000"; 200].join(",");
let v = parse_slide_range(&text, 100_000).unwrap();
assert_eq!(v.len(), 100_000);
assert!(v.windows(2).all(|w| w[0] < w[1]));
assert_eq!(parse_slide_range("3, 1-3, 2", 3).unwrap(), vec![2, 0, 1]);
}
#[test]
fn slide_range_rejects_plus_sign() {
assert!(parse_slide_range("+2", 3).is_err());
+1 -1
View File
@@ -8,7 +8,7 @@ use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot,
use serde_json::{Value, json};
use std::sync::Arc;
use super::text::{format_body, format_para, format_run};
use super::text::{format_body, format_para, format_run, format_run_levels};
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,
+25 -7
View File
@@ -1,6 +1,6 @@
use super::{
Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or,
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, json, ok, on_param, resolve, str_param,
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, format_run_levels, json, ok, on_param, resolve, str_param,
};
pub(super) fn bold(s: &mut Session, p: &Value) -> Result<Value> {
@@ -55,12 +55,18 @@ pub(super) fn size(s: &mut Session, p: &Value) -> Result<Value> {
format_run(s, &move |r| r.size = Some(v))
}
/// PowerPoint's font size list (grow/shrink steps through it).
pub const SIZES: [f64; 20] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, 48.0, 54.0, 60.0, 66.0];
/// PowerPoint's font size list, as the Font Size box lists it (grow/shrink steps through it).
pub const SIZES: [f64; 24] =
[8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, 48.0, 54.0, 60.0, 66.0, 72.0, 80.0, 88.0, 96.0];
/// Grow steps up the list, then 8 points at a time past its top; shrink steps back down the same
/// way. A size past the top used to shrink straight to 66 (#2059).
fn step(cur: f64, up: bool) -> f64 {
let top = SIZES[SIZES.len() - 1];
if up {
SIZES.iter().copied().find(|v| *v > cur + 0.01).unwrap_or((cur + 8.0).min(4000.0))
} else if cur - 0.01 > top {
(cur - 8.0).max(top)
} else {
SIZES.iter().rev().copied().find(|v| *v < cur - 0.01).unwrap_or((cur - 1.0).max(1.0))
}
@@ -72,10 +78,18 @@ fn grow_shrink(s: &mut Session, up: bool) -> Result<Value> {
let ctx_sizes = current_run(s).size.unwrap_or(18.0);
let _ = st;
let base = ctx_sizes;
format_run(s, &move |r| {
let cur = r.size.unwrap_or(base);
r.size = Some(step(cur, up));
})
// A layout/master level steps from its own effective size, not the first run's.
format_run_levels(
s,
&move |r| {
let cur = r.size.unwrap_or(base);
r.size = Some(step(cur, up));
},
&move |r, eff| {
let cur = r.size.or(eff.size).unwrap_or(18.0);
r.size = Some(step(cur, up));
},
)
}
pub(super) fn grow(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -100,6 +114,7 @@ pub(super) fn color(s: &mut Session, p: &Value) -> Result<Value> {
edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| {
if let ShapeKind::Chart(ch) = &mut sh.kind {
ch.text_color = Some(tc.clone());
ch.legend_entries.iter_mut().for_each(|e| e.color = None);
ch.raw = None;
}
Ok(())
@@ -227,6 +242,9 @@ pub(super) fn language_all(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color");
if c.is_none() && p.get("color").is_some_and(|v| !v.is_null()) {
return Err(bad("format.highlight", "`color` is not a valid colour"));
}
format_run(s, &move |r| r.highlight = c.clone())
}
+17 -11
View File
@@ -14,6 +14,7 @@ pub(super) fn align(s: &mut Session, p: &Value) -> Result<Value> {
"justify" | "just" => Align::Justify,
"justifyLow" | "justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
"thaiDist" => Align::ThaiDistributed,
_ => Align::Left,
};
align_to(s, a)
@@ -45,12 +46,18 @@ pub(super) fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
format_para(s, &move |para| {
if on {
para.props.bullet = Some(Bullet::Char { char: ch.clone() });
para.props.bullet_font = if explicit_char { Some("Arial".into()) } else { para.props.bullet_font.clone() };
if explicit_char {
para.props.bullet_font = Some("Arial".into());
para.props.bullet_font_follow_text = None;
}
if col.is_some() {
para.props.bullet_color = col.clone();
para.props.bullet_color_follow_text = None;
}
if size.is_some() {
para.props.bullet_size = size;
para.props.bullet_size_points = None;
para.props.bullet_size_follow_text = None;
}
if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
para.props.margin_left = Some(18.0 + 36.0 * para.level as f64);
@@ -76,9 +83,12 @@ pub(super) fn numbering(s: &mut Session, p: &Value) -> Result<Value> {
para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start });
if col.is_some() {
para.props.bullet_color = col.clone();
para.props.bullet_color_follow_text = None;
}
if size.is_some() {
para.props.bullet_size = size;
para.props.bullet_size_points = None;
para.props.bullet_size_follow_text = None;
}
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
para.props.indent = Some(-27.0);
@@ -110,13 +120,7 @@ pub(super) fn line_spacing(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result<Value> {
let a = str_param(p, "align").map(|a| match a {
"center" => Align::Center,
"right" => Align::Right,
"justify" => Align::Justify,
"distributed" => Align::Distributed,
_ => Align::Left,
});
let a = str_param(p, "align").map(|a| parse_align("format.paragraph", a)).transpose()?;
let ml = f64_param(p, "indentLeft");
let ind = f64_param(p, "indentFirst");
let sb = f64_param(p, "spaceBefore");
@@ -172,12 +176,13 @@ pub(super) fn clamp_pt(v: f64) -> f64 {
fn parse_align(cmd: &str, s: &str) -> Result<Align> {
Ok(match s {
"left" | "l" => Align::Left,
"center" | "ctr" => Align::Center,
"center" | "centre" | "ctr" => Align::Center,
"right" | "r" => Align::Right,
"justify" | "just" => Align::Justify,
"justLow" => Align::JustLow,
"justLow" | "justifyLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")),
"thaiDist" => Align::ThaiDistributed,
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
})
}
@@ -189,6 +194,7 @@ fn align_name(a: Align) -> &'static str {
Align::Justify => "justify",
Align::JustLow => "justLow",
Align::Distributed => "distributed",
Align::ThaiDistributed => "thaiDist",
}
}
+144 -6
View File
@@ -80,6 +80,12 @@ pub(super) fn photo_album_edit(s: &mut Session, p: &Value) -> Result<Value> {
let opts = album_opts(p)?;
let items = album_items(p)?;
let (id, ids) = album_target(s, p)?;
// The editor is seeded within the album limits; replacing the album from a trimmed seed would
// delete what it left out, so refuse rather than lose it.
let st = s.doc()?;
if items_from_slides(&st.doc, &album_slides(st, &ids)).1 {
return Err(bad("insert.photoAlbumEdit", "this album holds more than the editor can carry; editing it would delete the rest"));
}
commit_album(s, items, opts, Some((id, ids)))
}
@@ -545,6 +551,19 @@ fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec<u64>)> {
Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"))
}
/// Whether a slide holds anything besides album items: shapes, notes, comments, a background, a
/// transition, animations, a colour map or tags.
fn has_slide_content(s: &deckcraft_model::Slide) -> bool {
!s.shapes.is_empty()
|| !s.notes.text().trim().is_empty()
|| !s.comments.is_empty()
|| s.background.is_some()
|| s.transition.is_some()
|| !s.animations.is_empty()
|| s.color_map.is_some()
|| !s.tags.is_empty()
}
fn pic_count(slide: &deckcraft_model::Slide) -> usize {
slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count()
}
@@ -592,9 +611,16 @@ fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String,
(layout, frame.into(), captions, any && gray, theme_now.to_string())
}
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
/// The album's items for the dialog, within the limits `insert.photoAlbum` itself takes (items,
/// bytes per picture and in all), checked before a picture is encoded: an album slide set can
/// name one large picture many times.
///
/// The flag is true when something was left out for a limit.
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> (Vec<Value>, bool) {
let mut items = Vec::new();
for slide in slides {
let mut total = 0usize;
let mut omitted = false;
'slides: for slide in slides {
let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
if pics.is_empty() {
let text = slide
@@ -605,13 +631,27 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
.find(|t| !t.trim().is_empty())
.unwrap_or_default();
if !text.trim().is_empty() {
if items.len() >= ALBUM_MAX_ITEMS {
omitted = true;
break 'slides;
}
items.push(json!({"text": text.trim()}));
} else if items.len() >= ALBUM_MAX_ITEMS && has_slide_content(slide) {
// Past the item limit, a slide with other content would be deleted by a replace.
omitted = true;
break 'slides;
}
continue;
}
for sh in pics {
let ShapeKind::Picture { fill } = &sh.kind else { continue };
let media = doc.media(fill.media);
let size = media.map_or(0, |m| m.data.len());
if items.len() >= ALBUM_MAX_ITEMS || size > ALBUM_MAX_BYTES || total.saturating_add(size) > ALBUM_MAX_TOTAL {
omitted = true;
break 'slides;
}
total += size;
let name = if !sh.descr.is_empty() {
sh.descr.clone()
} else {
@@ -626,14 +666,19 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
}));
}
}
items
(items, omitted)
}
/// The slides of album `ids` as the editor would seed them.
fn album_slides<'a>(st: &'a crate::DocState, ids: &[u64]) -> Vec<&'a deckcraft_model::Slide> {
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect()
}
fn album_seed(s: &Session) -> Result<Value> {
let (id, ids) = album_target(s, &Value::Null)?;
let st = s.doc()?;
let slides: Vec<&deckcraft_model::Slide> =
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect();
let slides = album_slides(st, &ids);
let (items, _) = items_from_slides(&st.doc, &slides);
let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into());
let inferred = infer_album(&slides, &theme_now);
let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark));
@@ -655,7 +700,7 @@ fn album_seed(s: &Session) -> Result<Value> {
"captions": captions,
"grayscale": grayscale,
"theme": theme,
"items": items_from_slides(&st.doc, &slides),
"items": items,
}))
}
@@ -722,3 +767,96 @@ fn insert_album(s: &mut Session, items: Vec<AlbumItem>, opts: &AlbumOpts, at: Op
s.execute("slide.go", &json!({"index": first}))?;
Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id}))
}
#[cfg(test)]
mod seed_tests {
use std::sync::Arc;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{Shape, ShapeId, defaults};
use super::*;
#[test]
fn reopening_an_album_seeds_at_most_the_items_an_album_takes() {
// One picture named by 500 shapes: the dialog gets 48 items, not 500 encoded copies.
let mut p = defaults::new_presentation(None);
let img = p.add_media("a.png", "image/png", vec![1; 64 * 1024]).unwrap();
let pic = |k: u32| Shape {
id: ShapeId(10_000 + k),
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
..Default::default()
};
Arc::make_mut(&mut p.slides[0]).shapes = (0..500).map(pic).collect();
let slides: Vec<&deckcraft_model::Slide> = p.slides.iter().map(|s| s.as_ref()).collect();
let (items, omitted) = items_from_slides(&p, &slides);
assert_eq!(items.len(), ALBUM_MAX_ITEMS);
assert!(omitted);
let few: Vec<&deckcraft_model::Slide> = Vec::new();
assert!(!items_from_slides(&p, &few).1);
}
/// An album too big for the editor's seed cannot be replaced from that seed: the replace would
/// delete what the seed left out.
#[test]
fn editing_an_album_the_editor_cannot_hold_is_refused() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
let st = s.doc_mut().unwrap();
let doc = Arc::make_mut(&mut st.doc);
let mut extra = doc.slides.last().unwrap().as_ref().clone();
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
doc.slides.push(Arc::new(extra));
let n = doc.slides.len();
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
assert!(e.contains("delete the rest"), "{e}");
assert_eq!(s.doc().unwrap().doc.slides.len(), n);
}
/// Past the item limit, a slide holding only a drawing counts as content the replace would delete.
#[test]
fn editing_an_album_with_a_drawing_slide_past_the_limit_is_refused() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
let st = s.doc_mut().unwrap();
let doc = Arc::make_mut(&mut st.doc);
let mut extra = doc.slides.last().unwrap().as_ref().clone();
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
extra.shapes = vec![Shape { id: ShapeId(90_000), ..Default::default() }];
doc.slides.push(Arc::new(extra));
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
assert!(e.contains("delete the rest"), "{e}");
}
/// Notes or comments on a shapeless slide past the limit are content a replace would delete.
#[test]
fn editing_an_album_with_a_notes_only_slide_past_the_limit_is_refused() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
let st = s.doc_mut().unwrap();
let doc = Arc::make_mut(&mut st.doc);
let mut extra = doc.slides.last().unwrap().as_ref().clone();
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
extra.shapes.clear();
extra.notes = deckcraft_model::text::TextBody::from_text("speaker notes");
doc.slides.push(Arc::new(extra));
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
assert!(e.contains("delete the rest"), "{e}");
}
/// A shapeless slide with a background, transition, colour map or tags counts as content; a plain empty one does not.
#[test]
fn slide_level_settings_count_as_slide_content() {
use deckcraft_model::Slide;
assert!(!has_slide_content(&Slide::default()));
assert!(has_slide_content(&Slide { tags: vec![("k".into(), "v".into())], ..Default::default() }));
assert!(has_slide_content(&Slide { color_map: Some(vec![("bg1".into(), "lt1".into())]), ..Default::default() }));
assert!(has_slide_content(&Slide { transition: Some(Default::default()), ..Default::default() }));
}
}
+1
View File
@@ -190,6 +190,7 @@ pub(super) fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
with_chart(s, p, "chart.options", |c| {
if let Some(t) = &text_color {
c.text_color = t.clone();
c.legend_entries.iter_mut().for_each(|e| e.color = None);
}
apply_bin_options(c, bin_count, bin_size);
apply_subtotals_and_bars(c, p, subtotals.as_deref());
+146 -1
View File
@@ -1,16 +1,18 @@
//! Inspection for agents: the deck, slides and shapes as JSON (verify work without screenshots).
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::Hyperlink;
use deckcraft_model::{Shape, ShapeKind};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, str_param, usize_param, xfrm_of};
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, opt_usize_param, str_param, usize_param, xfrm_of};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document),
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide),
cmd!(query "slide.actions", "Inspect Slide Actions", [], None, "{index?} → {index, slideId, actions: [{owner, event, type, target, hyperlink}]}; lists shape click/hover actions and text-run links/hover actions (including table cells) without executing them", has_doc, actions),
cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape),
cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands),
]
@@ -135,6 +137,70 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
}))
}
fn actions(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = opt_usize_param(p, "index", "slide.actions")?.unwrap_or(st.selection.slide);
let sl = st.doc.slides.get(i).ok_or_else(|| bad("slide.actions", format!("no slide {i}")))?;
let mut found = Vec::new();
for sh in &sl.shapes {
collect_actions(sh, &mut Vec::new(), &mut found);
}
Ok(json!({"index": i, "slideId": sl.id, "actions": found}))
}
fn collect_actions(sh: &Shape, path: &mut Vec<Value>, found: &mut Vec<Value>) {
path.push(json!({"id": sh.id, "name": sh.name}));
let owner = json!({"shapeId": sh.id, "shapeName": sh.name, "path": path});
if let Some(link) = &sh.click {
found.push(action_entry(owner.clone(), "click", link));
}
if let Some(link) = &sh.hover {
found.push(action_entry(owner.clone(), "hover", link));
}
if let Some(text) = &sh.text {
collect_text_actions(text, &owner, found);
}
if let ShapeKind::Table(table) = &sh.kind {
for (row_index, row) in table.rows.iter().enumerate() {
for (cell_index, cell) in row.cells.iter().enumerate() {
let mut cell_owner = owner.clone();
cell_owner["tableRowIndex"] = json!(row_index);
cell_owner["tableCellIndex"] = json!(cell_index);
collect_text_actions(&cell.text, &cell_owner, found);
}
}
}
if let ShapeKind::Group { children, .. } = &sh.kind {
for child in children {
collect_actions(child, path, found);
}
}
path.pop();
}
fn collect_text_actions(text: &deckcraft_model::text::TextBody, owner: &Value, found: &mut Vec<Value>) {
for (paragraph_index, paragraph) in text.paragraphs.iter().enumerate() {
for (run_index, run) in paragraph.runs.iter().enumerate() {
for (event, link) in [("textLink", run.props.link.as_ref()), ("textHover", run.props.hover.as_ref())] {
if let Some(link) = link {
let mut text_owner = owner.clone();
text_owner["paragraphIndex"] = json!(paragraph_index);
text_owner["runIndex"] = json!(run_index);
text_owner["text"] = json!(run.content());
found.push(action_entry(text_owner, event, link));
}
}
}
}
}
fn action_entry(owner: Value, event: &str, link: &Hyperlink) -> Value {
let action = serde_json::to_value(&link.action).unwrap_or_default();
let kind = action.get("kind").cloned().unwrap_or(Value::Null);
let target = ["url", "slide", "name", "path"].iter().find_map(|key| action.get(key).cloned()).unwrap_or(Value::Null);
json!({"owner": owner, "event": event, "type": kind, "target": target, "hyperlink": link})
}
fn shape(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id =
@@ -154,3 +220,82 @@ fn commands(s: &mut Session, p: &Value) -> Result<Value> {
.collect(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use deckcraft_geom::Xfrm;
use deckcraft_model::table::Row;
use deckcraft_model::text::{Action, Paragraph, Run, RunProps, TextBody};
use deckcraft_model::{Cell, Shape, ShapeId, ShapeKind, Table};
fn link(action: Action) -> Hyperlink {
Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }
}
#[test]
fn slide_actions_lists_group_shape_and_text_actions() {
let mut s = Session::with_new();
let mut child = Shape { id: ShapeId(12), name: "Child".into(), ..Default::default() };
child.click = Some(link(Action::NextSlide));
child.hover = Some(link(Action::Url { url: "https://example.com/hover".into() }));
let run_props = RunProps { link: Some(link(Action::Slide { slide: deckcraft_model::SlideId(7) })), ..Default::default() };
let mut paragraph = Paragraph::new("linked");
paragraph.runs = vec![Run::with("linked", run_props)];
child.text = Some(TextBody { paragraphs: vec![paragraph], ..Default::default() });
let group = Shape {
id: ShapeId(11),
name: "Group".into(),
kind: ShapeKind::Group { children: vec![child], child: Xfrm::default() },
..Default::default()
};
let st = s.doc_mut().unwrap();
let slide_index = st.selection.slide;
let presentation = std::sync::Arc::make_mut(&mut st.doc);
std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes = vec![group];
let v = s.execute("slide.actions", &json!({})).unwrap();
let actions = v["actions"].as_array().unwrap();
assert_eq!(actions.len(), 3, "{v}");
assert_eq!(actions[0]["event"], "click");
assert_eq!(actions[0]["type"], "nextSlide");
assert_eq!(actions[0]["owner"]["path"][0]["name"], "Group");
assert_eq!(actions[0]["owner"]["path"][1]["id"], 12);
assert_eq!(actions[1]["event"], "hover");
assert_eq!(actions[1]["target"], "https://example.com/hover");
assert_eq!(actions[2]["event"], "textLink");
assert_eq!(actions[2]["owner"]["runIndex"], 0);
assert_eq!(actions[2]["target"], 7);
let cell_props = RunProps { hover: Some(link(Action::Url { url: "https://example.com/cell".into() })), ..Default::default() };
let mut cell_text = TextBody::from_text("cell link");
cell_text.paragraphs[0].runs = vec![Run::with("cell link", cell_props)];
let table =
Table { rows: vec![Row { cells: vec![Cell { text: cell_text, ..Default::default() }], ..Default::default() }], ..Default::default() };
let table_shape = Shape { id: ShapeId(13), name: "Table".into(), kind: ShapeKind::Table(table), ..Default::default() };
let st = s.doc_mut().unwrap();
let presentation = std::sync::Arc::make_mut(&mut st.doc);
std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes.push(table_shape);
let v = s.execute("slide.actions", &json!({})).unwrap();
let table_action = v["actions"].as_array().unwrap().last().cloned().unwrap();
assert_eq!(table_action["event"], "textHover");
assert_eq!(table_action["owner"]["tableRowIndex"], 0);
assert_eq!(table_action["owner"]["tableCellIndex"], 0);
assert_eq!(table_action["target"], "https://example.com/cell");
}
#[test]
fn slide_actions_returns_empty_array_for_slide_without_actions() {
let mut s = Session::with_new();
let v = s.execute("slide.actions", &json!({})).unwrap();
assert_eq!(v["actions"], json!([]));
}
#[test]
fn slide_actions_refuses_a_bad_index_instead_of_using_the_selection() {
let mut s = Session::with_new();
for index in [json!(-1), json!("0"), json!(1.5)] {
assert!(s.execute("slide.actions", &json!({"index": index})).is_err(), "{index}");
}
}
}
+42 -1
View File
@@ -246,8 +246,17 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
list.insert(at + k, sh);
}
list.retain(|x| !ids.contains(&x.id));
sel.shapes = new_ids.clone();
let _ = list;
if sel.target == crate::Target::Slides
&& let Some(sl) = doc.slides.get_mut(sel.slide)
{
let sl = std::sync::Arc::make_mut(sl);
sl.animations.retain(|a| !ids.contains(&a.shape));
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| ids.contains(&t))) {
a.trigger = None;
}
}
sel.shapes = new_ids.clone();
Ok(())
})?;
Ok(json!({"ids": new_ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
@@ -338,4 +347,36 @@ mod tests {
assert!(matches!(out.kind, ShapeKind::Shape) && out.text.is_none());
assert!(matches!(out.fill, Some(deckcraft_model::style::Fill::Solid { .. })), "{:?}", out.fill);
}
#[test]
fn merge_cleans_animation_targets_and_triggers_for_every_operation_and_undo_restores_them() {
for op in ["union", "combine", "fragment", "intersect", "subtract"] {
let mut s = Session::with_new();
let first = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 100, 100]})).expect("first shape")["id"]
.as_u64()
.expect("id") as u32;
let second = s.execute("shape.insert", &json!({"preset": "rect", "rect": [50, 0, 100, 100]})).expect("second shape")["id"]
.as_u64()
.expect("id") as u32;
let unrelated = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 0, 100, 100]})).expect("unrelated shape")["id"]
.as_u64()
.expect("id") as u32;
s.execute("animation.add", &json!({"effect": "fade", "ids": [second]})).expect("operand animation");
s.execute("animation.add", &json!({"effect": "spin", "ids": [unrelated]})).expect("unrelated animation");
s.execute("animation.timing", &json!({"index": 1, "trigger": first})).expect("trigger animation");
s.execute("shape.merge", &json!({"op": op, "ids": [first, second]})).expect("merge");
let animations = &s.doc().expect("doc").doc.slides[0].animations;
assert_eq!(animations.len(), 1, "{op} removed animations for merged operands");
assert_eq!(animations[0].shape.0, unrelated, "{op} preserved the unrelated effect");
assert_eq!(animations[0].trigger, None, "{op} cleared the removed shape trigger");
s.execute("edit.undo", &json!({})).expect("undo merge");
let animations = &s.doc().expect("doc").doc.slides[0].animations;
assert_eq!(animations.len(), 2, "{op} undo restored removed effects");
assert_eq!(animations[0].shape.0, second);
assert_eq!(animations[1].shape.0, unrelated);
assert_eq!(animations[1].trigger.map(|id| id.0), Some(first));
}
}
}
+8 -1
View File
@@ -135,7 +135,14 @@ pub fn apply(sh: &mut Shape, id: &str) -> bool {
let e = Effects {
outer_shadow: lk.shadow.map(shadow),
soft_edge: lk.soft_edge,
reflection: lk.reflection.then_some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
reflection: lk.reflection.then_some(Reflection {
blur: 0.5,
start_alpha: 0.5,
end_alpha: 0.0,
end_pos: 0.35,
dist: 0.0,
..Default::default()
}),
bevel: lk.bevel.map(str::to_string),
..Default::default()
};
+437 -4
View File
@@ -6,16 +6,155 @@
//! renumbered with animations, triggers and connector ends following them. Links to another
//! reused slide follow its copy.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
use deckcraft_model::table::TableStyleDef;
use deckcraft_model::text::{Action, Hyperlink, ListStyle, RunProps, Spacing, TextBody};
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use serde_json::{Value, json};
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
use crate::{Result, Session, media_refs};
/// Scale physical measurements in imported content to the destination slide's coordinate space.
/// Text has one size, so it uses the geometric mean of the horizontal and vertical scale.
fn scale_text(t: &mut TextBody, sx: f64, sy: f64) {
let unit = (sx * sy).sqrt();
for v in [&mut t.body.inset_l, &mut t.body.inset_r, &mut t.body.col_spacing].into_iter().flatten() {
*v *= sx;
}
for v in [&mut t.body.inset_t, &mut t.body.inset_b].into_iter().flatten() {
*v *= sy;
}
scale_list(&mut t.list_style, sx, unit);
for p in &mut t.paragraphs {
for v in [&mut p.props.margin_left, &mut p.props.margin_right, &mut p.props.indent, &mut p.props.default_tab].into_iter().flatten() {
*v *= sx;
}
if let Some(stops) = &mut p.props.tabs {
for stop in stops {
stop.pos *= sx;
}
}
for spacing in [&mut p.props.line_spacing, &mut p.props.space_before, &mut p.props.space_after] {
if let Some(Spacing::Pts(v)) = spacing {
*v *= unit;
}
}
if let Some(v) = &mut p.props.bullet_size_points {
*v *= unit;
}
scale_run(&mut p.def_props, unit);
scale_run(&mut p.end_props, unit);
for r in &mut p.runs {
scale_run(&mut r.props, unit);
}
}
}
fn scale_list(list: &mut ListStyle, sx: f64, unit: f64) {
for level in list.levels.iter_mut().flatten() {
for v in
[&mut level.para.margin_left, &mut level.para.margin_right, &mut level.para.indent, &mut level.para.default_tab].into_iter().flatten()
{
*v *= sx;
}
for spacing in [&mut level.para.line_spacing, &mut level.para.space_before, &mut level.para.space_after] {
if let Some(Spacing::Pts(v)) = spacing {
*v *= unit;
}
}
if let Some(v) = &mut level.para.bullet_size_points {
*v *= unit;
}
scale_run(&mut level.run, unit);
}
}
fn scale_run(run: &mut RunProps, factor: f64) {
for v in [&mut run.size, &mut run.spacing, &mut run.kern, &mut run.soft_edge].into_iter().flatten() {
*v *= factor;
}
}
fn scale_shapes(shapes: &mut [deckcraft_model::Shape], sx: f64, sy: f64, depth: usize) {
if depth > 64 {
return;
}
let unit = (sx * sy).sqrt();
for shape in shapes {
if let Some(x) = &mut shape.xfrm {
x.x *= sx;
x.w *= sx;
x.y *= sy;
x.h *= sy;
}
if let Some(text) = &mut shape.text {
scale_text(text, sx, sy);
}
match &mut shape.kind {
ShapeKind::Table(table) => {
for col in &mut table.cols {
*col *= sx;
}
for row in &mut table.rows {
row.height *= sy;
for cell in &mut row.cells {
if let Some(margins) = &mut cell.margins {
if let Some(v) = margins.get_mut(0) {
*v *= sx;
}
if let Some(v) = margins.get_mut(1) {
*v *= sx;
}
if let Some(v) = margins.get_mut(2) {
*v *= sy;
}
if let Some(v) = margins.get_mut(3) {
*v *= sy;
}
}
scale_text(&mut cell.text, sx, sy);
}
}
}
ShapeKind::Group { children, child } => {
child.x *= sx;
child.w *= sx;
child.y *= sy;
child.h *= sy;
scale_shapes(children, sx, sy, depth + 1);
}
_ => {}
}
if let Some(effects) = &mut shape.effects {
for shadow in [&mut effects.outer_shadow, &mut effects.inner_shadow].into_iter().flatten() {
shadow.blur *= unit;
shadow.dist *= unit;
}
if let Some(glow) = &mut effects.glow {
glow.radius *= unit;
}
if let Some(reflection) = &mut effects.reflection {
reflection.blur *= unit;
reflection.dist *= unit;
}
}
}
}
fn scale_master(master: &mut Master, sx: f64, sy: f64) {
scale_shapes(&mut master.shapes, sx, sy, 0);
let unit = (sx * sy).sqrt();
scale_list(&mut master.title_style, sx, unit);
scale_list(&mut master.body_style, sx, unit);
scale_list(&mut master.other_style, sx, unit);
for layout in &mut master.layouts {
scale_shapes(&mut layout.shapes, sx, sy, 0);
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -152,6 +291,90 @@ fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::Layout
.unwrap_or_default()
}
fn collect_table_styles(shapes: &[deckcraft_model::Shape], styles: &mut Vec<String>, seen: &mut HashSet<String>, depth: usize) {
if depth > 64 {
return;
}
for shape in shapes {
if let ShapeKind::Table(table) = &shape.kind
&& seen.insert(table.style.to_ascii_lowercase())
{
styles.push(table.style.clone());
}
collect_table_styles(shape.children(), styles, seen, depth + 1);
}
}
fn remap_table_styles(shapes: &mut [deckcraft_model::Shape], styles: &HashMap<String, String>, depth: usize) {
if depth > 64 {
return;
}
for shape in shapes {
if let ShapeKind::Table(table) = &mut shape.kind
&& let Some(style) = styles.get(&table.style.to_ascii_lowercase())
{
table.style = style.clone();
}
if let Some(children) = shape.children_mut() {
remap_table_styles(children, styles, depth + 1);
}
}
}
fn same_table_style(a: &TableStyleDef, b: &TableStyleDef) -> bool {
a.name == b.name && a.parts == b.parts && a.background == b.background && a.raw == b.raw
}
/// Copy only definitions used on selected slides. Conflicting GUIDs receive a fresh GUID so
/// existing destination tables continue to resolve to their original definitions.
fn import_table_styles(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[String]) -> Result<HashMap<String, String>> {
let mut map = HashMap::new();
let mut source_defs = HashMap::new();
let mut source_ids = HashSet::new();
for (index, style) in src.table_styles.iter().enumerate() {
let key = style.id.to_ascii_lowercase();
source_defs.entry(key.clone()).or_insert(index);
source_ids.insert(key);
}
let mut destination_defs = HashMap::new();
for (index, style) in doc.table_styles.iter().enumerate() {
destination_defs.entry(style.id.to_ascii_lowercase()).or_insert(index);
}
for id in used {
let key = id.to_ascii_lowercase();
if map.contains_key(&key) {
continue;
}
let Some(source_index) = source_defs.get(&key).copied() else { continue };
let Some(source) = src.table_styles.get(source_index) else { continue };
let destination = destination_defs.get(&key).and_then(|index| doc.table_styles.get(*index));
let mut imported = source.clone();
if let Some(existing) = destination {
if same_table_style(existing, source) {
map.insert(key, existing.id.clone());
continue;
}
let candidate = loop {
let n = doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?;
let candidate = format!("{{00000000-0000-4000-8000-{n:012X}}}");
let key = candidate.to_ascii_lowercase();
if !destination_defs.contains_key(&key) && !source_ids.contains(&key) {
break candidate;
}
};
// The definition's own XML names it too, and PPTX export writes that XML as is.
imported.raw = imported.raw.replacen(&format!("styleId=\"{}\"", source.id), &format!("styleId=\"{candidate}\""), 1);
imported.id.clone_from(&candidate);
map.insert(key, candidate);
} else {
map.insert(key, imported.id.clone());
}
destination_defs.insert(imported.id.to_ascii_lowercase(), doc.table_styles.len());
doc.table_styles.push(imported);
}
Ok(map)
}
fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes) = super::insert::media_bytes(p, "slide.reuse")?;
let src = super::file::open_presentation(&name, &bytes)?;
@@ -176,8 +399,23 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
}
let keep = bool_or(p, "keepFormatting", false);
let at = usize_param(p, "at");
let source_size = src.slide_size;
s.edit(|doc, sel| {
let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len());
let mut used_table_styles = Vec::new();
let mut seen_table_styles = HashSet::new();
for &i in &picked {
if let Some(slide) = src.slides.get(i) {
collect_table_styles(&slide.shapes, &mut used_table_styles, &mut seen_table_styles, 0);
}
}
let table_style_map = import_table_styles("slide.reuse", doc, &src, &used_table_styles)?;
let scale = |source: f64, destination: f64| {
let ratio = destination / source;
if source > 0.0 && destination > 0.0 && ratio.is_finite() && ratio > 0.0 { ratio } else { 1.0 }
};
let sx = scale(source_size.width, doc.slide_size.width);
let sy = scale(source_size.height, doc.slide_size.height);
// Media: what the slides use is copied under fresh ids.
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
// Keep Source Formatting: bring each source master used, once, with fresh ids.
@@ -190,7 +428,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
masters_done.push(m.id);
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
let mut m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
scale_master(&mut m, sx, sy);
doc.masters.push(Arc::new(m));
}
}
@@ -201,6 +440,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
let Some(from) = src.slides.get(i) else { continue };
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
let mut sl: Slide = from.as_ref().clone();
remap_table_styles(&mut sl.shapes, &table_style_map, 0);
scale_shapes(&mut sl.shapes, sx, sy, 0);
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
@@ -284,13 +525,205 @@ fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[Med
#[cfg(test)]
mod tests {
use deckcraft_geom::{Size, Xfrm};
use deckcraft_model::style::{Artistic, Fill, PictureFill};
use deckcraft_model::table::TableStyleDef;
use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody};
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults};
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, Table, defaults};
use serde_json::json;
use super::*;
const CUSTOM_STYLE: &str = "{11111111-2222-4333-8444-555555555555}";
fn styled_table_presentation(id: &str, color: &str) -> Presentation {
let mut p = deckcraft_model::defaults::new_presentation(None);
let raw = format!(
r#"<a:tblStyle xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" styleId="{id}" styleName="Owned"><a:wholeTbl><a:tcStyle><a:fill><a:solidFill><a:srgbClr val="{color}"/></a:solidFill></a:fill></a:tcStyle></a:wholeTbl></a:tblStyle>"#
);
p.table_styles.push(TableStyleDef { id: id.into(), name: "Owned".into(), raw, ..Default::default() });
let table = deckcraft_model::Table { style: id.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
let child = deckcraft_model::Shape { id: ShapeId(p.alloc_id().expect("shape id")), kind: ShapeKind::Table(table), ..Default::default() };
let group = deckcraft_model::Shape {
id: ShapeId(p.alloc_id().expect("group id")),
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
..Default::default()
};
let slide = p.slides.first_mut().expect("default slide");
Arc::make_mut(slide).shapes.push(group);
p
}
fn first_table_style(shapes: &[deckcraft_model::Shape]) -> Option<String> {
for shape in shapes {
if let ShapeKind::Table(table) = &shape.kind {
return Some(table.style.clone());
}
if let Some(style) = first_table_style(shape.children()) {
return Some(style);
}
}
None
}
#[test]
fn reused_grouped_table_styles_survive_native_and_pptx_round_trips_and_undo() {
for (name, bytes) in [
("source.deckcraft", deckcraft_format::save(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("native source")),
("source.pptx", deckcraft_pptx::export(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("pptx source")),
] {
let data = super::super::base64_encode(&bytes);
let mut session = Session::with_new();
session.execute("slide.reuse", &json!({"data": data, "name": name, "slides": [0], "keepFormatting": true})).expect("reuse");
let doc = &session.doc().expect("doc").doc;
assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("FF0000")));
assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
session.execute("edit.undo", &json!({})).expect("undo reuse");
let doc = &session.doc().expect("doc").doc;
assert!(doc.table_styles.iter().all(|style| style.id != CUSTOM_STYLE));
session.execute("edit.redo", &json!({})).expect("redo reuse");
let bytes = crate::cmd::file::save_bytes(&session.doc().expect("doc").doc, "deckcraft").expect("save destination");
let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &bytes).expect("reopen destination");
assert!(reopened.table_styles.iter().any(|style| style.id == CUSTOM_STYLE));
assert_eq!(first_table_style(&reopened.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
}
}
#[test]
fn reused_table_style_definitions_are_deduplicated_or_remapped_on_collision() {
let source = styled_table_presentation(CUSTOM_STYLE, "FF0000");
let source_bytes = deckcraft_format::save(&source).expect("save source");
let mut identical = Session::with_new();
identical
.edit(|doc, _| {
doc.table_styles.push(source.table_styles.first().cloned().unwrap_or_default());
Ok(())
})
.expect("seed identical definition");
identical
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
.expect("reuse identical definition");
let doc = &identical.doc().expect("doc").doc;
assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
let mut conflicting = Session::with_new();
conflicting
.edit(|doc, _| {
let mut own = source.table_styles.first().cloned().unwrap_or_default();
own.raw = own.raw.replace("FF0000", "0000FF");
doc.table_styles.push(own);
let table = deckcraft_model::Table { style: CUSTOM_STYLE.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
let id = ShapeId(doc.alloc_id().expect("destination table id"));
if let Some(slide) = doc.slides.first_mut() {
Arc::make_mut(slide).shapes.push(Shape { id, kind: ShapeKind::Table(table), ..Default::default() });
}
Ok(())
})
.expect("seed conflicting definition");
conflicting
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
.expect("reuse conflicting definition");
let doc = &conflicting.doc().expect("doc").doc;
assert_eq!(first_table_style(&doc.slides.first().expect("destination slide").shapes).as_deref(), Some(CUSTOM_STYLE));
let imported_style = first_table_style(&doc.slides.get(1).expect("reused slide").shapes).expect("grouped table style");
assert_ne!(imported_style, CUSTOM_STYLE);
assert!(
doc.table_styles.iter().any(|style| style.id == imported_style
&& style.raw.contains("FF0000")
&& style.raw.contains(&format!("styleId=\"{imported_style}\"")))
);
assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("0000FF")));
}
#[test]
fn reused_slide_content_fits_destination_size() {
let mut src = defaults::new_presentation(None);
src.slide_size = Size::new(960.0, 540.0);
let slide_id = src.slides.first().map(|s| s.id).unwrap_or_default();
if let Some(slide) = src.slides.first_mut().map(Arc::make_mut) {
let mut text = TextBody::from_text("scaled");
if let Some(run) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
run.props.size = Some(24.0);
}
slide.shapes = vec![
Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 200.0, 100.0)), ..Default::default() },
Shape {
xfrm: Some(Xfrm::new(40.0, 50.0, 400.0, 200.0)),
text: Some(text),
kind: ShapeKind::Table({
let mut table = Table::new(2, 2, 400.0, 40.0);
table.rows[0].cells[0].text = TextBody::from_text("cell");
if let Some(run) = table.rows[0].cells[0].text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
run.props.size = Some(18.0);
}
table
}),
..Default::default()
},
Shape {
xfrm: Some(Xfrm::new(100.0, 80.0, 300.0, 150.0)),
kind: ShapeKind::Group {
child: Xfrm::new(0.0, 0.0, 300.0, 150.0),
children: vec![Shape { xfrm: Some(Xfrm::new(200.0, 100.0, 80.0, 40.0)), ..Default::default() }],
},
..Default::default()
},
];
}
if let Some(master) = src.masters.first_mut().map(Arc::make_mut) {
master.shapes = vec![Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 100.0, 50.0)), ..Default::default() }];
if let Some(layout) = master.layouts.first_mut() {
layout.shapes = vec![Shape { xfrm: Some(Xfrm::new(700.0, 300.0, 100.0, 50.0)), ..Default::default() }];
}
}
let bytes = deckcraft_format::save(&src).expect("source save");
let data = super::super::base64_encode(&bytes);
let mut session = Session::with_new();
session
.edit(|doc, _| {
doc.slide_size = Size::new(480.0, 270.0);
Ok(())
})
.expect("destination size");
session.execute("slide.reuse", &json!({"data": data, "name": "source.deckcraft", "slides": [0], "keepFormatting": true})).expect("reuse");
let doc = &session.doc().expect("doc").doc;
let reused = doc.slides.get(1).expect("reused slide");
let first = reused.shapes.first().and_then(|s| s.xfrm).expect("shape transform");
assert_eq!(first, Xfrm::new(400.0, 200.0, 100.0, 50.0));
let table_shape = reused.shapes.get(1).expect("table");
let table_box = table_shape.xfrm.expect("table transform");
assert_eq!(table_box, Xfrm::new(20.0, 25.0, 200.0, 100.0));
assert!(matches!(&table_shape.kind, ShapeKind::Table(_)));
let ShapeKind::Table(table) = &table_shape.kind else { return };
assert_eq!(table.cols, vec![100.0, 100.0]);
assert_eq!(table.rows.first().map(|r| r.height), Some(20.0));
let font_size = table_shape.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).and_then(|r| r.props.size);
assert_eq!(font_size, Some(12.0));
let cell_size = table.rows[0].cells[0].text.paragraphs.first().and_then(|p| p.runs.first()).and_then(|r| r.props.size);
assert_eq!(cell_size, Some(9.0));
let group = reused.shapes.get(2).expect("group");
assert!(matches!(&group.kind, ShapeKind::Group { .. }));
let ShapeKind::Group { children, child } = &group.kind else { return };
assert_eq!(group.xfrm, Some(Xfrm::new(50.0, 40.0, 150.0, 75.0)));
assert_eq!(*child, Xfrm::new(0.0, 0.0, 150.0, 75.0));
assert_eq!(children.first().and_then(|s| s.xfrm), Some(Xfrm::new(100.0, 50.0, 40.0, 20.0)));
let imported_master = doc.masters.last().expect("imported master");
assert_eq!(imported_master.shapes.first().and_then(|s| s.xfrm), Some(Xfrm::new(400.0, 200.0, 50.0, 25.0)));
assert_eq!(imported_master.layouts.first().and_then(|l| l.shapes.first()).and_then(|s| s.xfrm), Some(Xfrm::new(350.0, 150.0, 50.0, 25.0)));
assert_ne!(reused.id, slide_id);
let saved = deckcraft_format::save(&doc.clone()).expect("destination save");
let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &saved).expect("destination reopen");
let reopened_shape = reopened.slides.get(1).and_then(|s| s.shapes.first()).and_then(|s| s.xfrm);
assert_eq!(reopened_shape, Some(Xfrm::new(400.0, 200.0, 100.0, 50.0)));
}
/// Every kind of media a slide can hold comes along, each pointing at its own bytes: the
/// artistic-effect original, a background picture, poster, sounds and picture bullets too.
#[test]
+91 -7
View File
@@ -219,7 +219,7 @@ fn neighbour(s: &mut Session, p: &Value, forward: bool) -> Result<Value> {
let cmd = if forward { "comment.next" } else { "comment.prev" };
let (threads, slide, index) = {
let st = s.doc()?;
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index"))
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), super::opt_usize_param(p, "index", cmd)?)
};
if threads.is_empty() {
return Err(bad(cmd, "no comments"));
@@ -367,7 +367,7 @@ fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
}
out.push(v);
}
let rep: Vec<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
let rep: std::collections::HashSet<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
for w in crate::spell::paragraph_words(para, &default) {
if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) {
continue;
@@ -421,23 +421,30 @@ fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result<Value> {
/// Replace whole-word `from` with `to` in `text`; how many were replaced.
fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize {
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
let mut n = 0;
let mut at = 0;
// The result is built in one pass; the text is not edited in place for every match.
let mut out = String::new();
let (mut n, mut at, mut copied) = (0, 0, 0);
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
let end = i + from.len();
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
// The character before the match as the edited text would have it.
let before = text.get(copied..i).and_then(|t| t.chars().next_back()).or_else(|| out.chars().next_back()).is_some_and(is_word);
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
if before || after {
at = end;
continue;
}
text.replace_range(i..end, to);
out.push_str(text.get(copied..i).unwrap_or_default());
out.push_str(to);
n += 1;
at = i + to.len();
(at, copied) = (end, end);
if !all {
break;
}
}
if n > 0 {
out.push_str(text.get(copied..).unwrap_or_default());
*text = out;
}
n
}
@@ -764,6 +771,49 @@ fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
/// The replacement as it ran before: edited in place for every match.
fn replace_word_in_place(text: &mut String, from: &str, to: &str, all: bool) -> usize {
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
let mut n = 0;
let mut at = 0;
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
let end = i + from.len();
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
if before || after {
at = end;
continue;
}
text.replace_range(i..end, to);
n += 1;
at = i + to.len();
if !all {
break;
}
}
n
}
#[test]
fn replace_word_matches_the_in_place_version() {
let cases = [
("a a b a", "a", "x", true),
("aa a. a", "a", "bb", true),
("a-a -a-", "-", "ab", true),
("foo. foo.", "foo.", "", true),
("it's it", "it", "so", true),
("x.x. x.", "x.", "y", true),
("a a a", "a", "b", false),
("none", "z", "q", true),
("é é", "é", "e", true),
];
for (text, from, to, all) in cases {
let (mut a, mut b) = (text.to_string(), text.to_string());
let (na, nb) = (replace_word(&mut a, from, to, all), replace_word_in_place(&mut b, from, to, all));
assert_eq!((na, a), (nb, b), "{text:?} {from:?} -> {to:?}");
}
}
use super::*;
use serde_json::json;
@@ -812,6 +862,40 @@ mod tests {
assert_eq!(here["text"], "on first");
}
#[test]
fn malformed_comment_navigation_index_does_not_change_selection() {
let mut s = Session::with_new();
s.execute("comment.add", &json!({"text": "first"})).unwrap();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
s.execute("comment.add", &json!({"text": "second"})).unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
let shape = s.doc().unwrap().doc.slides.first().and_then(|slide| slide.shapes.first()).map(|shape| shape.id).unwrap();
s.doc_mut().unwrap().selection.shapes = vec![shape];
for (command, value) in [
("comment.next", json!(-1)),
("comment.next", json!(1.5)),
("comment.next", json!("bad")),
("comment.prev", json!(-1)),
("comment.prev", json!(1.5)),
("comment.prev", json!("bad")),
] {
let before = s.doc().unwrap().selection.clone();
let error = s.execute(command, &json!({"index": value})).unwrap_err();
assert!(error.to_string().contains("valid index"), "{command} {value}: {error}");
assert_eq!(s.doc().unwrap().selection, before, "{command} {value}");
}
let zero = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap();
assert_eq!(zero["slide"], 1);
assert_eq!(zero["index"], 0);
s.execute("slide.go", &json!({"index": 0})).unwrap();
let omitted = s.execute("comment.next", &json!({})).unwrap();
assert_eq!(omitted["slide"], 0);
assert_eq!(omitted["index"], 0);
}
#[test]
fn timestamps_are_iso_utc() {
assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
+8
View File
@@ -135,6 +135,14 @@ fn eye_show_all_and_hide_all() {
assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0);
}
#[test]
fn lock_group_with_its_child_targeted_leaves_child_locked() {
let (mut s, ids) = deck();
let (d, g) = (ids[3], ids[7]);
s.execute("shape.lock", &json!({"ids": [g, d, d]})).unwrap();
assert!(flags(&s, g).1 && flags(&s, d).1);
}
#[test]
fn lock_cascades_through_groups_and_undoes() {
let (mut s, ids) = deck();
+21 -2
View File
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
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,
has_slide, id_param, ok, opt_usize_param, str_param, targets, usize_param, with_param, xfrm_of,
};
use crate::{Result, Session};
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Fill",
["Shape Format", "Shape Styles"],
None,
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool (false keeps linear and radial gradients upright on the slide; rectangular and path gradients turn with the shape when drawn)} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
has_selection,
fill
),
@@ -374,6 +374,25 @@ fn visible(s: &mut Session, p: &Value) -> Result<Value> {
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
let v = bool_param(p, "locked");
// A targeted shape inside another targeted group is reached through the group: editing it again
// would toggle it back.
let mut ids = super::targets(s, p)?;
if ids.len() > 1 {
let doc = s.doc()?;
// Walk each distinct group once, however often `ids` repeats it.
let mut once = std::collections::HashSet::new();
ids.retain(|id| once.insert(*id));
let mut inside = std::collections::HashSet::new();
for id in &ids {
if let Some(g) = doc.shape(*id) {
deckcraft_model::walk(g.children(), &mut |c, _| {
inside.insert(c.id);
});
}
}
ids.retain(|id| !inside.contains(id));
}
let p = &json!({"ids": ids, "locked": p.get("locked")});
edit_shapes(s, p, "shape.lock", |sh| {
let to = v.unwrap_or(!sh.locked);
set_locked(sh, to, 0);
+34 -9
View File
@@ -1,11 +1,11 @@
use super::{
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, targets, usize_param,
edit_shapes, f64_param, inherited_fills, json, opt_usize_param, requested_fill, str_param, targets,
};
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(0).min(16);
let i = opt_usize_param(p, "index", "shape.adjust")?.unwrap_or(0).min(16);
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
edit_geometry(s, p, "shape.adjust", |sh| {
if let Geom::Preset { name, adj } = &mut sh.geom {
@@ -74,7 +74,8 @@ pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
/// rotateWithShape?}`.
/// rotateWithShape?}`. The renderer keeps only linear and radial gradients upright when
/// `rotateWithShape` is false; rectangular and path gradients turn with the shape.
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
let stops: Vec<GradientStop> = v
.get("stops")?
@@ -108,7 +109,13 @@ pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
scaled: false,
},
};
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
Some(Gradient {
tile: [0.0; 4],
stops,
shape,
rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true),
flip: v.get("flip").and_then(Value::as_str).and_then(deckcraft_model::style::GradientFlip::from_xml),
})
}
/// Fill and outline apply to a group's visible members as well as its container. Other edits
@@ -365,9 +372,9 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0)));
let refl = str_param(p, "reflection").map(|r| match r {
"none" => None,
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }),
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }),
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0, ..Default::default() }),
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0, ..Default::default() }),
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0, ..Default::default() }),
});
let three_d = p.get("threeD").filter(|v| v.is_object());
let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) });
@@ -461,7 +468,14 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
e.glow = (g.radius > 0.0).then_some(g);
}
if let Some(v) = &refl_props {
let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 });
let mut r = e.reflection.take().unwrap_or(Reflection {
blur: 0.5,
start_alpha: 0.5,
end_alpha: 0.0,
end_pos: 0.35,
dist: 0.0,
..Default::default()
});
if let Some(t) = num(v, "transparency") {
r.start_alpha = 1.0 - t.clamp(0.0, 1.0);
}
@@ -542,7 +556,7 @@ pub fn quick_style_for(index: usize) -> (ShapeStyle, Option<Fill>, Option<Line>)
}
pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(1).min(41);
let i = opt_usize_param(p, "index", "shape.quickStyle")?.unwrap_or(1).min(41);
let (st, f, l) = quick_style_for(i);
edit_shapes(s, p, "shape.quickStyle", |sh| {
sh.style = Some(st.clone());
@@ -555,3 +569,14 @@ pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gradient_parameters_keep_flip_mode() {
let gradient = gradient_param(&json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]], "flip": "xy"})).expect("gradient");
assert_eq!(gradient.flip, Some(deckcraft_model::style::GradientFlip::Xy));
}
}
+16 -5
View File
@@ -3,8 +3,9 @@ use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad
pub(super) fn sites(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
let st = s.doc()?;
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
// In slide space, as shape.connect uses them, for a shape inside a group too.
let v = crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
Ok(json!(v.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
}
pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
@@ -12,8 +13,13 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("shape.connect", "missing `from` / `to`"));
};
let st = s.doc()?;
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
let (a, b) = (site_list(from), site_list(to));
// Anywhere in the slide's tree: a shape inside a group takes a connector too (PowerPoint
// glues to grouped shapes), its sites where the group draws them.
let site_list = |id| {
crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id)
.ok_or_else(|| bad("shape.connect", format!("no shape {} on the current slide", id.0)))
};
let (a, b) = (site_list(from)?, site_list(to)?);
if a.is_empty() || b.is_empty() {
return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)"));
}
@@ -35,6 +41,11 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
};
let (p0, p1) = (a[ia], b[ib]);
let id = match id_param(p, "id", "shape.connect")? {
// The ends are in slide space and rerouting follows the slide's own connectors, so a line
// inside a group would be drawn in the group's space and never follow its shapes.
Some(id) if !st.shapes().iter().any(|sh| sh.id == id) && st.shape(id).is_some_and(|sh| sh.is_line()) => {
return Err(bad("shape.connect", "`id` names a line inside a group; ungroup it first, or leave `id` out for a new connector"));
}
Some(id) => id,
None => {
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
@@ -93,7 +104,7 @@ pub(super) fn freeform(s: &mut Session, p: &Value) -> Result<Value> {
a.iter()
.filter_map(|v| Some(deckcraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)))
.filter(|q| q.x.is_finite() && q.y.is_finite())
.take(20_000)
.take(crate::tools::MAX_GESTURE_POINTS)
.collect()
})
.unwrap_or_default();
+7
View File
@@ -191,6 +191,13 @@ pub(super) fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn flip(s: &mut Session, p: &Value) -> Result<Value> {
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
edit_geometry(s, p, "shape.flip", |sh| {
// PowerPoint greys Flip out for a SmartArt graphic (SmartArt Design > Right to Left mirrors it),
// also inside a group: ungrouping would hand the flip to the graphic.
let mut graphic = false;
deckcraft_model::walk(std::slice::from_ref(&*sh), &mut |x, _| graphic |= x.diagram.is_some());
if graphic {
return Err(bad("shape.flip", "Flip is not available for a SmartArt graphic; use `smartArt.layout` to change how it is laid out"));
}
if let Some(x) = sh.xfrm.as_mut() {
if v {
x.flip_v = !x.flip_v;
+44 -1
View File
@@ -267,6 +267,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
for sec in &mut doc.sections {
sec.slides.retain(|x| !ids.contains(x));
}
for show in &mut doc.custom_shows {
show.slides.retain(|x| !ids.contains(x));
}
sel.slide = first.min(doc.slides.len().saturating_sub(1));
sel.slides = doc.slides.get(sel.slide).map(|x| vec![x.id]).unwrap_or_default();
sel.shapes.clear();
@@ -405,8 +408,9 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
};
s.select(|doc, sel| {
if add {
let mut have: std::collections::HashSet<_> = sel.slides.iter().copied().collect();
for i in &ids {
if !sel.slides.contains(i) {
if have.insert(*i) {
sel.slides.push(*i);
}
}
@@ -777,6 +781,45 @@ mod tests {
(sel.slide, sel.slides.iter().map(|x| x.0).collect())
}
#[test]
fn deleting_slide_prunes_every_custom_show_in_one_undo_step() {
let (mut s, ids) = deck(3);
let shows = vec![
deckcraft_model::CustomShow {
name: "First".into(),
slides: vec![deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])],
},
deckcraft_model::CustomShow { name: "Second".into(), slides: vec![deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])] },
deckcraft_model::CustomShow { name: "Empty after delete".into(), slides: vec![deckcraft_model::SlideId(ids[1])] },
];
s.edit(|doc, _| {
doc.custom_shows = shows;
Ok(())
})
.expect("set custom shows");
s.execute("slide.delete", &json!({"index": 1})).expect("delete slide");
let shows = &s.doc().expect("doc").doc.custom_shows;
assert_eq!(shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
assert_eq!(shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
assert!(shows[2].slides.is_empty());
s.execute("edit.undo", &json!({})).expect("undo deletion");
assert_eq!(order(&s), ids);
assert_eq!(
s.doc().expect("doc").doc.custom_shows[0].slides,
[deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])]
);
assert_eq!(s.doc().expect("doc").doc.custom_shows[1].slides, [deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])]);
s.execute("edit.redo", &json!({})).expect("redo deletion");
let bytes = deckcraft_format::save(&s.doc().expect("doc").doc).expect("save deck");
let reopened = deckcraft_format::load(&bytes).expect("reopen deck");
assert_eq!(reopened.custom_shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
assert_eq!(reopened.custom_shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
assert!(reopened.custom_shows[2].slides.is_empty());
}
#[test]
fn move_from_to_moves_one_slide() {
let (mut s, ids) = deck(4);
+1
View File
@@ -99,6 +99,7 @@ pub(super) fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) {
shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true,
tile: [0.0; 4],
flip: None,
}));
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0));
sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() });
+43 -21
View File
@@ -253,9 +253,10 @@ fn cell_param(p: &Value, key: &str, cmd: &str) -> Result<Option<(usize, usize)>>
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))),
// Exactly [row, col]: an entry with more is as malformed as one with less.
let idx = |x: &Value| x.as_u64().and_then(|x| usize::try_from(x).ok());
match v.as_array().map(Vec::as_slice) {
Some([r, c]) if let (Some(r), Some(c)) = (idx(r), idx(c)) => Ok(Some((r, c))),
_ => Err(bad(cmd, format!("`{key}` is not a valid cell: {v}"))),
}
}
@@ -389,33 +390,54 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// At most this many `cells` in one table cell command.
const MAX_CELLS: usize = 100_000;
/// The cells a command acts on: `cells`, else the selected cell range, else the cell holding the
/// caret, else every cell.
fn target_cells(s: &Session, p: &Value) -> Result<impl Fn(&Table) -> Vec<(usize, usize)> + use<>> {
let cells: Option<Vec<(usize, usize)>> = p
.get("cells")
.and_then(Value::as_array)
.map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect());
fn target_cells(s: &Session, p: &Value, cmd: &str) -> Result<impl Fn(&Table) -> Result<Vec<(usize, usize)>> + use<>> {
// `cells` is a list of [row, col]: anything else, or an entry that is not one, is refused, never
// dropped or read as absent (which would act on the selection or the whole table).
let cells: Option<Vec<(usize, usize)>> = match p.get("cells") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) if a.len() > MAX_CELLS => {
return Err(bad(cmd, format!("`cells` lists {} cells; at most {MAX_CELLS}", a.len())));
}
Some(Value::Array(a)) => Some(
a.iter()
.map(|c| {
cell_param(&json!({ "cell": c }), "cell", cmd)?.ok_or_else(|| bad(cmd, format!("`cells` entry {c} is not a [row, col] cell")))
})
.collect::<Result<_>>()?,
),
Some(v) => return Err(bad(cmd, format!("`cells` is a list of [row, col] cells, not {v}"))),
};
let st = s.doc()?;
// A selected range grows over the merged cells it touches; with only a caret, the caret's cell is used.
let sel = st.selection.cells.map(|(_, a, b)| (a, b, true)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c, false)));
let cmd = cmd.to_string();
Ok(move |t: &Table| match (&cells, sel) {
(Some(c), _) => c.clone(),
// Each listed cell must be in the table, so a command never reports success for a cell
// it could not edit.
(Some(c), _) => match c.iter().find(|(r, c)| *r >= t.n_rows() || *c >= t.n_cols()) {
Some((r, c)) => Err(bad(&cmd, format!("no cell [{r}, {c}]: the table has {} rows and {} columns", t.n_rows(), t.n_cols()))),
None => Ok(c.clone()),
},
(None, Some((a, b, range))) => {
let (a, b) = if range { cell_block(t, a, b) } else { (a, b) };
// The selection can name cells outside the table; only walk the ones that exist.
let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1)));
(a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect()
Ok((a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect())
}
_ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(),
_ => Ok((0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect()),
})
}
fn cell_fill(s: &mut Session, p: &Value) -> Result<Value> {
let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) };
let targets = target_cells(s, p)?;
let targets = target_cells(s, p, "table.cellFill")?;
with_table(s, p, "table.cellFill", move |t| {
for (r, c) in targets(t) {
for (r, c) in targets(t)? {
if let Some(cell) = t.cell_mut(r, c) {
cell.fill.clone_from(&fill);
}
@@ -447,9 +469,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
}
// "none" clears to no line, so the table style's own lines don't show through either.
let line = if which == "none" { Line::none() } else { line };
let targets = target_cells(s, p)?;
let targets = target_cells(s, p, "table.borders")?;
with_table(s, p, "table.borders", move |t| {
let list = targets(t);
let list = targets(t)?;
let (Some(r0), Some(r1), Some(c0), Some(c1)) =
(list.iter().map(|x| x.0).min(), list.iter().map(|x| x.0).max(), list.iter().map(|x| x.1).min(), list.iter().map(|x| x.1).max())
else {
@@ -463,9 +485,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
}
let n = match side {
LEFT => c.checked_sub(1).map(|c| (r, c, RIGHT)),
RIGHT => (c + 1 < cols).then_some((r, c + 1, LEFT)),
RIGHT => c.checked_add(1).filter(|c| *c < cols).map(|c| (r, c, LEFT)),
TOP => r.checked_sub(1).map(|r| (r, c, BOTTOM)),
_ => (r + 1 < rows).then_some((r + 1, c, TOP)),
_ => r.checked_add(1).filter(|r| *r < rows).map(|r| (r, c, TOP)),
};
if let Some((r, c, side)) = n
&& let Some(cell) = t.cell_mut(r, c)
@@ -523,9 +545,9 @@ fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
};
let side = |k: &str, i: usize| f64_param(p, k).filter(|v| v.is_finite()).map_or(base[i], |v| v.clamp(0.0, 400.0));
let m = [side("left", 0), side("right", 1), side("top", 2), side("bottom", 3)];
let targets = target_cells(s, p)?;
let targets = target_cells(s, p, "table.cellMargins")?;
with_table(s, p, "table.cellMargins", move |t| {
for (r, c) in targets(t) {
for (r, c) in targets(t)? {
if let Some(cell) = t.cell_mut(r, c) {
cell.margins = Some(m);
}
@@ -560,9 +582,9 @@ fn cell_align(s: &mut Session, p: &Value) -> Result<Value> {
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
}),
};
let targets = target_cells(s, p)?;
let targets = target_cells(s, p, "table.cellAlign")?;
with_table(s, p, "table.cellAlign", move |t| {
for (r, c) in targets(t) {
for (r, c) in targets(t)? {
if let Some(cell) = t.cell_mut(r, c) {
if anchor.is_some() {
cell.anchor = anchor;
+1 -1
View File
@@ -30,7 +30,7 @@ use editing::{edit, exit};
pub(crate) use editing::{insert_field, insert_text, paste_body};
pub use formatting::format_range;
use formatting::{fonts_used, replace_font};
pub(crate) use formatting::{format_body, format_para, format_run, link_text};
pub(crate) use formatting::{format_body, format_para, format_run, format_run_levels, link_text};
pub use ime::{IME_SURROUNDING_MAX_BYTES, ImeSurrounding, cell_range_underlines, composition_underlines, ime_surrounding, range_underlines};
use ime::{commit, compose};
pub(crate) use layout::ctx_for;
+123 -9
View File
@@ -8,22 +8,34 @@ use super::{
/// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour),
/// or sets the formatting for what is typed next at a word boundary.
pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result<Value> {
format_run_levels(s, f, &|r, _| f(r))
}
/// Like [`format_run`], with a separate change for the list-style levels of a layout or master
/// placeholder: `lf` gets the level's own run props and the level's effective ones (what a run at
/// that level resolves to), so a relative change (grow/shrink) steps each level from its own value.
pub(crate) fn format_run_levels(
s: &mut Session,
f: &(dyn Fn(&mut RunProps) + Send + Sync),
lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync),
) -> Result<Value> {
let st = s.doc()?;
if let Some(t) = st.selection.text.clone() {
let (a, b) = format_range(st, &t);
let collapsed = a == b;
// Only the levels of the paragraphs the selected range touches.
let levels = if a == b { Vec::new() } else { master_levels(st, &t, t.ordered()) };
edit_text(s, |body, _| {
if collapsed {
// Next typed text: put the props on an empty paragraph's end mark or keep pending.
if levels.is_empty() {
ed::format(body, a, b, f);
} else {
ed::format(body, a, b, f);
format_by_level(body, a, b, lf, &levels);
}
for (l, eff) in &levels {
let mut lvl = body.list_style.level(*l).cloned().unwrap_or_default();
lf(&mut lvl.run, eff);
body.list_style.set(*l, lvl);
}
})?;
if collapsed {
// Remember as pending typing props by inserting nothing: handled via end mark for
// empty paragraphs; mid-paragraph carets keep surrounding formatting.
}
return ok();
}
let ids = st.selection.shapes.clone();
@@ -31,6 +43,19 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
}
let cells = st.selection.cells;
// Effective run props of every level of the selected placeholders, resolved before editing.
let effective: Vec<(ShapeId, Vec<RunProps>)> = if editing_master(st) {
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection);
ids.iter()
.filter_map(|id| {
let sh = st.shape(*id).filter(|sh| sh.ph.is_some())?;
let ctx = ctx.as_ref()?;
Some((*id, (0..LIST_LEVELS).map(|l| deckcraft_model::resolve::level_style(ctx, sh, l).run).collect()))
})
.collect()
} else {
Vec::new()
};
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
let id = sh.id;
match &mut sh.kind {
@@ -47,7 +72,22 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
}
_ => {
let body = sh.text.get_or_insert_with(TextBody::default);
ed::format_all(body, f);
let eff = effective.iter().find(|(i, _)| *i == id).map(|(_, e)| e);
match eff {
Some(eff) => {
let levels: Vec<(u8, RunProps)> = (0..LIST_LEVELS).zip(eff.iter().cloned()).collect();
format_by_level(body, (0, 0), (usize::MAX, usize::MAX), lf, &levels);
}
None => ed::format_all(body, f),
}
// The whole shape is selected: every level of its list style.
if let Some(eff) = eff {
for (l, eff) in (0..LIST_LEVELS).zip(eff) {
let mut lvl = body.list_style.level(l).cloned().unwrap_or_default();
lf(&mut lvl.run, eff);
body.list_style.set(l, lvl);
}
}
}
}
Ok(())
@@ -58,6 +98,55 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
ok()
}
/// Format the runs of [a, b) paragraph by paragraph, each with its own level's effective props, so
/// a relative change (grow/shrink) steps every paragraph from its own size instead of one shared
/// value. `a`/`b` are clamped to the body.
fn format_by_level(body: &mut TextBody, a: Pos, b: Pos, lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync), levels: &[(u8, RunProps)]) {
let Some(last) = body.paragraphs.len().checked_sub(1) else { return };
let (first, end) = (a.0.min(last), b.0.min(last));
for pi in first..=end {
let p = &body.paragraphs[pi];
let from = if pi == first { a.1.min(p.char_len()) } else { 0 };
let to = if pi == end { b.1.min(p.char_len()) } else { p.char_len() };
if from == to && p.char_len() > 0 {
continue;
}
let lvl = p.level.min(LIST_LEVELS - 1);
let Some((_, eff)) = levels.iter().find(|(l, _)| *l == lvl) else { continue };
ed::format(body, (pi, from), (pi, to), &|r| lf(r, eff));
}
}
/// List-style levels (`lvl1pPr`–`lvl9pPr`) a placeholder passes on to the slides that use it.
const LIST_LEVELS: u8 = 9;
/// Formatting a placeholder on a layout or master (Slide Master view) also sets its list style,
/// which is what the placeholders on slides inherit; its own runs may be few or none.
fn editing_master(st: &DocState) -> bool {
matches!(st.selection.target, crate::Target::Layout { .. } | crate::Target::Master { .. })
}
/// The distinct list-style levels of the paragraphs `range` touches in a layout or master
/// placeholder, with each level's effective run props. Empty anywhere else.
fn master_levels(st: &DocState, t: &TextSel, range: (Pos, Pos)) -> Vec<(u8, RunProps)> {
if !editing_master(st) || t.notes || t.cell.is_some() {
return Vec::new();
}
let (Some(sh), Some(body), Some(ctx)) =
(st.shape(t.shape).filter(|sh| sh.ph.is_some()), body_of(st, t), crate::cmd::text::ctx_for(&st.doc, &st.selection))
else {
return Vec::new();
};
let mut levels: Vec<u8> =
body.paragraphs.iter().enumerate().filter(|(i, _)| (range.0.0..=range.1.0).contains(i)).map(|(_, p)| p.level.min(LIST_LEVELS - 1)).collect();
if body.paragraphs.is_empty() {
levels.push(0);
}
levels.sort_unstable();
levels.dedup();
levels.into_iter().map(|l| (l, deckcraft_model::resolve::level_style(&ctx, sh, l).run)).collect()
}
/// What `format_run` formats while editing: the selection, or for a bare caret the word it's in
/// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink
/// edits this same span, so the dialog reads it too.
@@ -110,6 +199,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
let st = s.doc()?;
if let Some(t) = st.selection.text.clone() {
let (a, b) = t.ordered();
// Only the list-style levels of the paragraphs touched, on a layout or master placeholder.
let levels: Vec<u8> = master_levels(st, &t, (a, b)).into_iter().map(|(l, _)| l).collect();
edit_text(s, |body, _| {
if body.paragraphs.is_empty() {
body.paragraphs.push(Default::default());
@@ -120,6 +211,9 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
f(p);
}
}
for l in levels {
format_list_level(body, l, f);
}
})?;
return ok();
}
@@ -128,8 +222,10 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
}
let cells = st.selection.cells;
let on_master = editing_master(st);
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
let id = sh.id;
let inherited = on_master && sh.ph.is_some();
match &mut sh.kind {
ShapeKind::Table(t) => {
let range = crate::cmd::table::selected_range(cells, id, t);
@@ -147,6 +243,12 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
body.paragraphs.push(Default::default());
}
body.paragraphs.iter_mut().for_each(f);
// The whole shape is selected: every level of its list style.
if inherited {
for l in 0..LIST_LEVELS {
format_list_level(body, l, f);
}
}
}
}
Ok(())
@@ -157,6 +259,18 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
ok()
}
/// Apply a paragraph change to one level of a body's list style.
fn format_list_level(body: &mut TextBody, l: u8, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) {
let mut lvl = body.list_style.level(l).cloned().unwrap_or_default();
let mut p = deckcraft_model::text::Paragraph { level: l, props: lvl.para, ..Default::default() };
f(&mut p);
// A level change (indent/outdent) means nothing for a list-style level.
if p.level == l {
lvl.para = p.props;
body.list_style.set(l, lvl);
}
}
/// Apply body (text box) changes to the edited or selected shapes.
pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::BodyProps) + Send + Sync)) -> Result<Value> {
let st = s.doc()?;
+33 -7
View File
@@ -6,7 +6,7 @@ use deckcraft_model::Transition;
use deckcraft_model::anim::TRANSITIONS;
use serde_json::{Value, json};
use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, str_param, usize_param};
use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, opt_usize_param, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -44,9 +44,9 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn slide_indices(s: &Session, p: &Value) -> Result<Vec<usize>> {
fn slide_indices(s: &Session, p: &Value, cmd: &str) -> Result<Vec<usize>> {
let st = s.doc()?;
if let Some(i) = usize_param(p, "index") {
if let Some(i) = opt_usize_param(p, "index", cmd)? {
return Ok(vec![i]);
}
let v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();
@@ -59,7 +59,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
TRANSITIONS.iter().find(|t| t.0.eq_ignore_ascii_case(kind)).ok_or_else(|| bad("transition.set", format!("unknown transition `{kind}`")))?;
let option = str_param(p, "option").map(String::from).or_else(|| info.4.first().map(|o| o.to_string())).unwrap_or_default();
let duration = p.get("duration").and_then(Value::as_u64).map(|v| v.min(59_000) as u32);
let list = slide_indices(s, p)?;
let list = slide_indices(s, p, "transition.set")?;
s.edit(|doc, _| {
for i in list {
if let Some(sl) = doc.slides.get_mut(i) {
@@ -83,7 +83,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let option = str_param(p, "option").ok_or_else(|| bad("transition.options", "missing `option`"))?.to_string();
let list = slide_indices(s, p)?;
let list = slide_indices(s, p, "transition.options")?;
s.edit(|doc, _| {
for i in list {
if let Some(sl) = doc.slides.get_mut(i)
@@ -98,7 +98,7 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
}
fn timing(s: &mut Session, p: &Value) -> Result<Value> {
let list = slide_indices(s, p)?;
let list = slide_indices(s, p, "transition.timing")?;
s.edit(|doc, _| {
for i in list {
if let Some(sl) = doc.slides.get_mut(i) {
@@ -127,7 +127,7 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
/// Transitions › Sound: embed an audio file as the slides' transition sound, or `none` to remove it.
fn sound(s: &mut Session, p: &Value) -> Result<Value> {
let list = slide_indices(s, p)?;
let list = slide_indices(s, p, "transition.sound")?;
let looping = bool_param(p, "loop");
let edit_all = |s: &mut Session, f: &dyn Fn(&mut Transition)| {
s.edit(|doc, _| {
@@ -215,3 +215,29 @@ fn apply_all(s: &mut Session, _p: &Value) -> Result<Value> {
fn list(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(Value::Array(TRANSITIONS.iter().map(|t| json!({"id": t.0, "label": t.1, "category": t.2, "duration": t.3, "options": t.4})).collect()))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < 3 {
s.execute("slide.new", &json!({})).expect("new slide");
}
for bad in [json!(-1), json!(1.5)] {
for (cmd, p) in [
("transition.set", json!({"kind": "fade", "index": bad})),
("transition.options", json!({"option": "x", "index": bad})),
("transition.timing", json!({"duration": 500, "index": bad})),
("transition.sound", json!({"none": true, "index": bad})),
] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
assert!(s.doc().expect("doc").doc.slides.iter().all(|sl| sl.transition.is_none()), "{cmd} {p}");
}
}
}
}
+112 -18
View File
@@ -121,9 +121,10 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect();
let rev_matched: HashSet<usize> = pairs.iter().map(|p| p.1).collect();
let mut out = vec![];
let mut media = Media::new(cur, rev);
for (i, j) in &pairs {
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
let details = slide_details(cur, a, rev, b);
let details = slide_details(cur, a, rev, b, &mut media);
if !details.is_empty() {
out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details });
}
@@ -140,10 +141,18 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
});
}
}
// Each revised slide's open match (the first pair naming it).
let mut rev_to_cur: HashMap<usize, usize> = HashMap::new();
for p in &pairs {
rev_to_cur.entry(p.1).or_insert(p.0);
}
// After the open slide that matches the nearest matched revised slide before it.
let mut at = 0;
for (j, s) in rev.slides.iter().enumerate() {
if let Some(i) = rev_to_cur.get(&j) {
at = i + 1;
}
if !rev_matched.contains(&j) {
// After the open slide that matches the revised slide before it.
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
out.push(Change {
kind: ChangeKind::Inserted,
slide: at,
@@ -174,8 +183,34 @@ fn shape_label(s: &Shape) -> String {
if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() }
}
fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> {
p.media(id).map(|m| m.data.as_slice())
/// Both decks' media bytes by id, and which picture pairs were already compared: a deck can
/// show one picture many times, and its bytes are compared once per pair.
struct Media<'a> {
cur: HashMap<MediaId, &'a [u8]>,
rev: HashMap<MediaId, &'a [u8]>,
same: HashMap<(MediaId, MediaId), bool>,
}
impl<'a> Media<'a> {
fn new(cur: &'a Presentation, rev: &'a Presentation) -> Self {
// The first item with an id wins, as in `Presentation::media`.
let index = |p: &'a Presentation| {
let mut m = HashMap::new();
for x in &p.media {
m.entry(x.id).or_insert(x.data.as_slice());
}
m
};
Media { cur: index(cur), rev: index(rev), same: HashMap::new() }
}
/// Whether the pictures (by media id, `None` for a shape that is not a picture) differ.
fn differ(&mut self, a: Option<MediaId>, b: Option<MediaId>) -> bool {
let (ba, bb) = (a.and_then(|x| self.cur.get(&x)), b.and_then(|y| self.rev.get(&y)));
match (a, b) {
(Some(x), Some(y)) if ba.is_some() && bb.is_some() => !*self.same.entry((x, y)).or_insert_with(|| ba == bb),
_ => ba.is_some() || bb.is_some(),
}
}
}
/// A shape without its ids (shape and media), for comparing across decks.
@@ -204,7 +239,7 @@ fn turn(ctx: Option<&Ctx>, s: &Shape) -> (f64, bool, bool) {
drawn_box(ctx, s).map_or((0.0, false, false), |x| (x.rot, x.flip_h, x.flip_v))
}
fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentation, cb: Option<&Ctx>, b: &Shape, out: &mut Vec<String>) {
fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media: &mut Media, out: &mut Vec<String>) {
let label = shape_label(b);
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
let mut what = vec![];
@@ -234,11 +269,11 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
what.push("formatting");
}
let pic = |p: &Presentation, s: &Shape| match &s.kind {
ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()),
let pic = |s: &Shape| match &s.kind {
ShapeKind::Picture { fill } => Some(fill.media),
_ => None,
};
if pic(cp, a) != pic(rp, b) {
if media.differ(pic(a), pic(b)) {
what.push("picture");
}
// Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also
@@ -265,21 +300,32 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
}
}
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> {
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide, media: &mut Media) -> Vec<String> {
let mut out = vec![];
let mut used: HashSet<usize> = HashSet::new();
let (ca, cb) = (Ctx::for_slide(cp, a), Ctx::for_slide(rp, b));
// The revised shapes by id (first wins) and by name (in order, with a cursor past used ones).
let mut by_id: HashMap<ShapeId, usize> = HashMap::new();
let mut by_name: HashMap<&str, (Vec<usize>, usize)> = HashMap::new();
for (k, x) in b.shapes.iter().enumerate() {
by_id.entry(x.id).or_insert(k);
if !x.name.is_empty() {
by_name.entry(x.name.as_str()).or_default().0.push(k);
}
}
for sa in &a.shapes {
let k = b
.shapes
.iter()
.position(|x| x.id == sa.id)
.or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty()));
let k = by_id.get(&sa.id).copied().or_else(|| {
let (ks, next) = by_name.get_mut(sa.name.as_str())?;
while ks.get(*next).is_some_and(|k| used.contains(k)) {
*next += 1;
}
ks.get(*next).copied()
});
match k {
Some(k) if !used.contains(&k) => {
used.insert(k);
if let Some(sb) = b.shapes.get(k) {
shape_details(cp, ca.as_ref(), sa, rp, cb.as_ref(), sb, &mut out);
shape_details(ca.as_ref(), sa, cb.as_ref(), sb, media, &mut out);
}
}
_ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))),
@@ -334,19 +380,34 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
let mut sl = slide.clone();
// Media.
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
// Both decks' media by id (the first item with an id wins, as in `Presentation::media`),
// looked up once per reference instead of searched.
let mut rev_at: HashMap<MediaId, usize> = HashMap::new();
for (i, m) in rev.media.iter().enumerate() {
rev_at.entry(m.id).or_insert(i);
}
let mut doc_at: HashMap<MediaId, usize> = HashMap::new();
for (i, m) in doc.media.iter().enumerate() {
doc_at.entry(m.id).or_insert(i);
}
for id in crate::media_refs::slide_media(&sl) {
let Some(m) = rev.media(id) else { continue };
match doc.media(id) {
let Some(m) = rev_at.get(&id).and_then(|&i| rev.media.get(i)) else { continue };
match doc_at.get(&id).and_then(|&i| doc.media.get(i)) {
Some(x) if x.data == m.data => {}
None => {
// Keeps the revision's id, so later ids must start past it.
doc_at.insert(id, doc.media.len());
doc.media.push(m.clone());
doc.next_id = doc.next_id.max(id.0);
}
Some(_) => {
let before = doc.media.len();
let new = doc
.add_media(&m.name, &m.content_type, m.data.as_ref().clone())
.ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?;
if doc.media.len() > before {
doc_at.entry(new).or_insert(before);
}
media_map.insert(id, new);
}
}
@@ -519,6 +580,39 @@ mod tests {
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
}
#[test]
fn many_inserted_slides_and_same_named_pictures_compare_by_index() {
use std::sync::Arc;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{Shape, ShapeId, ShapeKind, SlideId, defaults};
// One slide of 3,000 pictures of one image, all named "Box"; the revision has the same
// pictures under other ids (matched by name, in order) and 3,000 slides inserted after it.
let mut cur = defaults::new_presentation(None);
let img = cur.add_media("a.png", "image/png", vec![7; 4096]).unwrap();
let mut rev = cur.clone();
let pic = |id: u32| Shape {
id: ShapeId(id),
name: "Box".into(),
xfrm: Some(deckcraft_model::Xfrm::new(10.0, 10.0, 50.0, 50.0)),
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
..Default::default()
};
Arc::make_mut(&mut cur.slides[0]).shapes = (0..3000).map(|k| pic(100_000 + k)).collect();
Arc::make_mut(&mut rev.slides[0]).shapes = (0..3000).map(|k| pic(200_000 + k)).collect();
let mut base = (*rev.slides[0]).clone();
base.shapes.clear();
for k in 0..3000 {
let mut s = base.clone();
s.id = SlideId(300_000 + k);
rev.slides.push(Arc::new(s));
}
let changes = super::diff(&cur, &rev, false);
assert_eq!(changes.len(), 3000, "only the inserted slides differ");
assert!(changes.iter().all(|c| c.kind == super::ChangeKind::Inserted && c.slide == 1));
}
/// A deck with one slide: a table whose cell has a picture fill, a picture background and a
/// transition sound. `tag` starts the bytes of each.
fn media_deck(tag: &str) -> deckcraft_model::Presentation {
+44 -5
View File
@@ -1,14 +1,21 @@
//! Connectors glued to shapes: connection sites, snapping line ends to them, and rerouting attached
//! connectors whenever the shapes they connect move, resize or rotate.
use deckcraft_geom::{Point, Xfrm};
use deckcraft_geom::{Affine, Point, Rect, Xfrm};
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use crate::Selection;
use crate::cmd::xfrm_of;
/// Connection sites of `s` in slide coordinates (index = site index stored on connectors).
/// Connection sites of a top-level shape `s` in slide coordinates (index = site index stored on
/// connectors). For a shape inside a group use [`sites_of`] or [`sites_in`].
pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
sites_in(doc, sel, s, Affine::IDENTITY)
}
/// Connection sites of `s` in slide coordinates, `to_abs` mapping the space its box lives in (the
/// slide, or the child space of the groups around it) onto the slide.
pub fn sites_in(doc: &Presentation, sel: &Selection, s: &Shape, to_abs: Affine) -> Vec<Point> {
if s.is_line() || matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Ink { .. }) {
return vec![];
}
@@ -17,10 +24,42 @@ pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
if local.is_empty() {
local = vec![Point::new(x.w / 2.0, 0.0), Point::new(0.0, x.h / 2.0), Point::new(x.w / 2.0, x.h), Point::new(x.w, x.h / 2.0)];
}
let a = x.affine();
let a = to_abs * x.affine();
local.into_iter().map(|p| a * p).collect()
}
/// The shape `id` anywhere in `shapes` (a group's member included) with the affine that maps the
/// space its box lives in onto the slide: a connector glues to a grouped shape, as in PowerPoint,
/// and its sites are where the group draws them.
pub fn locate<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId) -> Option<(&'a Shape, Affine)> {
fn rec<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId, to_abs: Affine, depth: usize) -> Option<(&'a Shape, Affine)> {
if depth > 64 {
return None;
}
for s in shapes {
if s.id == id {
return Some((s, to_abs));
}
if let ShapeKind::Group { children, child } = &s.kind {
let x = xfrm_of(doc, sel, s);
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
let nested = to_abs * deckcraft_geom::group_child_affine(&x, ch);
if let Some(f) = rec(doc, sel, children, id, nested, depth + 1) {
return Some(f);
}
}
}
None
}
rec(doc, sel, shapes, id, Affine::IDENTITY, 0)
}
/// Connection sites of shape `id` anywhere in `shapes`, in slide coordinates; `None` when there is
/// no such shape.
pub fn sites_of(doc: &Presentation, sel: &Selection, shapes: &[Shape], id: ShapeId) -> Option<Vec<Point>> {
locate(doc, sel, shapes, id).map(|(s, a)| sites_in(doc, sel, s, a))
}
/// The nearest connection site within `radius` of `p` on any visible shape except `exclude`.
pub fn nearest_site(
doc: &Presentation,
@@ -68,8 +107,8 @@ pub fn reroute(doc: &mut Presentation, sel: &Selection) {
let snapshot: Vec<Shape> = list.clone();
let view = doc.clone();
let site = |id: ShapeId, idx: u32| -> Option<Point> {
let s = snapshot.iter().find(|s| s.id == id)?;
let v = sites(&view, sel, s);
// Anywhere in the tree: a connector glued to a shape that is then grouped keeps following it.
let v = sites_of(&view, sel, &snapshot, id)?;
v.get(idx as usize).or(v.first()).copied()
};
let mut updates: Vec<(usize, Xfrm, bool, bool)> = vec![];
+26 -14
View File
@@ -85,14 +85,20 @@ impl AutomationRoots {
/// May the file or folder at `path` be read (or written)? `Err` says why not.
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
self.check_path(Path::new(path), access)
}
/// [`Self::check`] for a path that may not be UTF-8: the checked path is the one opened.
pub fn check_path(&self, path: &Path, access: Access) -> Result<(), String> {
let path_text = path.display();
let roots = match access {
Access::Read => &self.read,
Access::Write => &self.write,
};
if roots.is_empty() {
return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path_text}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path_text}")),
};
}
let resolved = resolve(path)?;
@@ -101,8 +107,8 @@ impl AutomationRoots {
}
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
match access {
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path_text}")),
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path_text}")),
}
}
}
@@ -191,17 +197,23 @@ pub fn check_write(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Write))
}
/// May `path` be written here? Always, without roots in force. For a path that may not be UTF-8.
pub fn check_write_path(path: &Path) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check_path(path, Access::Write))
}
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
/// existing folder's links followed and the rest appended.
fn resolve(path: &str) -> Result<PathBuf, String> {
if path.is_empty() {
fn resolve(path: &Path) -> Result<PathBuf, String> {
if path.as_os_str().is_empty() {
return Err("automation path rejected: path is empty".into());
}
let shown = path.display();
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
}
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
// The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![];
let mut at = absolute.as_path();
@@ -212,11 +224,11 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
return Ok(found);
}
Err(e) => match std::fs::symlink_metadata(at) {
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
},
}
let parent = at.parent();
@@ -226,12 +238,12 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
at = parent;
}
(Some(Component::ParentDir), _) => {
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
}
// Down to the root (or drive) and it doesn't exist either.
_ => {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
}
}
}
+11 -7
View File
@@ -1,6 +1,8 @@
//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text
//! draws in a stand-in; see `FontDb::document_face`).
use std::collections::HashSet;
use deckcraft_fonts::FontDb;
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::{Bullet, TextBody};
@@ -12,8 +14,13 @@ use deckcraft_model::{Presentation, Shape, ShapeKind};
/// font to load and are left for the round trip.
pub fn install_embedded_fonts(p: &Presentation) {
let db = FontDb::global();
// Faces can name one media item many times; each is copied and enumerated once.
let mut seen = HashSet::new();
for font in &p.embedded_fonts {
for id in font.faces().into_iter().flatten() {
if !seen.insert(id) {
continue;
}
let Some(item) = p.media(id) else { continue };
let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue };
db.add_font(sfnt.to_vec());
@@ -27,12 +34,8 @@ pub fn install_embedded_fonts(p: &Presentation) {
/// and can't be installed, so they aren't reported.
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
let db = FontDb::global();
let mut out: Vec<String> = Vec::new();
for family in used_fonts(p) {
if !family.starts_with('.') && !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
out.push(family);
}
}
// used_fonts is already free of case-insensitive duplicates.
let mut out: Vec<String> = used_fonts(p).into_iter().filter(|f| !f.starts_with('.') && !db.has_family(f)).collect();
out.sort_by_key(|f| f.to_lowercase());
out
}
@@ -40,9 +43,10 @@ pub fn missing_fonts(p: &Presentation) -> Vec<String> {
/// The families the slides' text runs and bullets are drawn in.
pub fn used_fonts(p: &Presentation) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut add = |f: String| {
let f = f.trim().to_string();
if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {
if !f.is_empty() && seen.insert(f.to_ascii_lowercase()) {
out.push(f);
}
};
+1 -1
View File
@@ -1,4 +1,4 @@
//! Last-resort guard (craftrules `standards/never-crash.md`): a panic that escapes a command —
//! Last-resort guard: a panic that escapes a command —
//! any edit, import or export, from the UI, the CLI, MCP or the control channel — becomes an
//! error, and the document is kept as it was before the command (and saved for crash recovery
//! when a recovery directory is set). It is a safety net: commands still must not panic.
+31 -2
View File
@@ -41,12 +41,15 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
Ok(canonical) => canonical,
Err(_) => std::path::PathBuf::from(path),
};
let target_str = target_path.to_str().unwrap_or(path);
let existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions());
let mut attempts = 0;
let (tmp, mut file) = loop {
let tmp = format!("{target_str}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
// A short fixed-size name: appending to the target's name could exceed the file name limit.
let tmp_name = format!(".deckcraft.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
let tmp = target_path.with_file_name(tmp_name);
// The sibling must pass the same check: when `path` is the write root itself it lands outside.
crate::file_access::check_write(&tmp.to_string_lossy())?;
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(f) => break (tmp, f),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
@@ -120,4 +123,30 @@ mod tests {
let _ = std::fs::remove_file(&test_file);
let _ = std::fs::remove_dir(&tmp_dir);
}
#[test]
fn write_file_overwrites_file_with_name_near_the_length_limit() {
let dir = std::env::temp_dir().join("deckcraft_io_long_name_test");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join(format!("{}.pptx", "a".repeat(250)));
std::fs::write(&file, b"old").unwrap();
let path_str = file.to_str().unwrap();
write_file(path_str, b"new").unwrap();
assert_eq!(read_file(path_str).unwrap(), b"new");
let _ = std::fs::remove_file(&file);
}
#[test]
fn write_file_to_the_write_root_itself_creates_no_sibling_outside_it() {
let base = std::env::temp_dir().join(format!("deckcraft_io_root_test_{}", std::process::id()));
let root = base.join("root");
std::fs::create_dir_all(&root).unwrap();
let roots = crate::file_access::AutomationRoots::new(&[], std::slice::from_ref(&root)).unwrap().unwrap();
let res = crate::file_access::confine(Some(&roots), || write_file(root.to_str().unwrap(), b"x"));
assert!(res.unwrap_err().contains("outside the write root"));
assert_eq!(std::fs::read_dir(&base).unwrap().count(), 1, "only the root is there");
let _ = std::fs::remove_dir_all(&base);
}
}
+41 -6
View File
@@ -454,17 +454,30 @@ pub struct MediaStatus {
}
/// Clipboard contents copied within the app.
/// The theme a copy drew with. The colours and the map stay apart, as in
/// `deckcraft_model::resolve::Ctx::color`: the map renames only `tx1`, `bg1`, `tx2` and `bg2`, and
/// a direct `dk1` keeps the theme's colour.
#[derive(Clone, Debug)]
pub struct SourceTheme {
/// The theme's own colours, no colour map applied.
pub colors: deckcraft_color::ColorScheme,
pub color_map: Vec<(String, String)>,
pub fonts: deckcraft_model::theme::FontScheme,
}
#[derive(Clone, Debug, Default)]
pub struct Clipboard {
pub shapes: Vec<Shape>,
/// The copied placeholders as they were, by id. `shapes` holds a slide's placeholders as
/// regular shapes; a paste in Slide Master view takes these, which stay placeholders.
pub placeholders: Vec<Shape>,
pub slides: Vec<Slide>,
/// Media the copied shapes/slides reference.
pub media: Vec<deckcraft_model::MediaItem>,
pub text: Option<deckcraft_model::TextBody>,
pub plain: String,
/// The copied shapes' theme colours (colour map applied) and fonts, for Paste Special ›
/// Keep Source Formatting.
pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>,
/// The theme the shapes were copied under, for Paste Special › Keep Source Formatting.
pub source_theme: Option<SourceTheme>,
/// How many times the shapes were pasted (each paste offsets further).
pub pastes: u32,
}
@@ -811,10 +824,32 @@ fn clamp_selection(st: &mut DocState) {
/// Default for [`History::byte_limit`].
pub const UNDO_BYTE_BUDGET: usize = 256 << 20;
/// A rough size of a text body in memory: its paragraphs and runs, at their inline struct sizes.
fn text_cost(t: &deckcraft_model::text::TextBody) -> usize {
use deckcraft_model::text::{Paragraph, Run};
let (para, run) = (std::mem::size_of::<Paragraph>(), std::mem::size_of::<Run>());
t.paragraphs.iter().map(|p| para + p.runs.iter().map(|r| run + r.text.len()).sum::<usize>()).sum()
}
/// A rough size of comments in memory, replies included.
fn comments_cost(cs: &[deckcraft_model::Comment], depth: usize) -> usize {
let kids = |c: &deckcraft_model::Comment| if depth < 64 { comments_cost(&c.replies, depth + 1) } else { 0 };
cs.iter().map(|c| 256 + c.author.len() + c.initials.len() + c.text.len() + c.date.len() + kids(c)).sum()
}
/// A rough size of a slide's own data besides its shapes: notes, comments, tags and kept XML,
/// all copied with the slide when an edit changes it.
fn slide_extra_cost(s: &deckcraft_model::Slide) -> usize {
text_cost(&s.notes)
+ comments_cost(&s.comments, 0)
+ s.name.len()
+ s.tags.iter().map(|(k, v)| 48 + k.len() + v.len()).sum::<usize>()
+ s.raw_ext.as_ref().map_or(0, String::len)
}
/// A rough size of a shape in memory: the struct, its text and kept XML, its children.
fn shape_cost(sh: &deckcraft_model::Shape, depth: usize) -> usize {
let text: usize =
sh.text.as_ref().map_or(0, |t| t.paragraphs.iter().map(|p| 96 + p.runs.iter().map(|r| 128 + r.text.len()).sum::<usize>()).sum());
let text: usize = sh.text.as_ref().map_or(0, text_cost);
let kids: usize = if depth < 64 { sh.children().iter().map(|c| shape_cost(c, depth + 1)).sum() } else { 0 };
768 + text + sh.raw_ext.as_ref().map_or(0, String::len) + kids
}
@@ -832,7 +867,7 @@ pub fn undo_cost(doc: &Presentation, newer: &Presentation) -> usize {
let media: HashSet<*const Vec<u8>> = newer.media.iter().map(|m| Arc::as_ptr(&m.data)).collect();
let mut n = 64 * doc.slides.len();
for s in doc.slides.iter().filter(|s| !slides.contains(&Arc::as_ptr(s))) {
n += 512 + shapes_cost(&s.shapes);
n += 512 + shapes_cost(&s.shapes) + slide_extra_cost(s);
}
for m in doc.masters.iter().filter(|m| !masters.contains(&Arc::as_ptr(m))) {
n += 4096 + shapes_cost(&m.shapes) + m.layouts.iter().map(|l| 512 + shapes_cost(&l.shapes)).sum::<usize>();
+10
View File
@@ -259,6 +259,16 @@ mod tests {
dir
}
#[test]
fn instance_identity_is_stable_nonzero_and_json_safe() {
let id = instance_id();
assert_eq!(instance_id(), id);
assert!(id != 0 && id <= UID_MASK);
assert_eq!(id & 0xFF_FFFF, 0);
let encoded = serde_json::to_value(id).unwrap();
assert_eq!(encoded.as_f64().unwrap() as u64, id);
}
#[test]
fn recovers_unsaved_presentations() {
let dir = temp("recovery");
+140 -19
View File
@@ -171,7 +171,9 @@ impl Dictionary {
["COMPOUNDWORDMAX", n, ..] => d.compound_max = n.parse().unwrap_or(0),
["REP", n] => {
for _ in 0..n.parse::<usize>().unwrap_or(0) {
let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect();
// The count is the file's claim: stop when the records run out.
let Some(line) = lines.next() else { break };
let r: Vec<&str> = line.split_whitespace().collect();
if let [_, a, b, ..] = r.as_slice() {
d.rep.push((a.replace('_', " "), b.replace('_', " ")));
}
@@ -182,7 +184,8 @@ impl Dictionary {
let Some(flag) = one_flag(Some(flag), mode) else { continue };
let cross = cross.eq_ignore_ascii_case("y");
for _ in 0..n.parse::<usize>().unwrap_or(0) {
let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect();
let Some(line) = lines.next() else { break };
let r: Vec<&str> = line.split_whitespace().collect();
let [_, _, strip, add, rest @ ..] = r.as_slice() else { continue };
let (add, cont) = match add.split_once('/') {
Some((a, c)) => (a, parse_flags(c, mode)),
@@ -304,7 +307,12 @@ impl Dictionary {
/// `w` made of dictionary parts allowed in compounds (`COMPOUNDFLAG`, or the begin, middle and
/// end flags), each at least `COMPOUNDMIN` characters long.
fn compounded(&self, w: &[char], index: usize) -> bool {
fn compounded(&self, w: &[char], index: usize, failed: &mut HashSet<(usize, usize)>) -> bool {
// `w` is always a tail of the checked word, so its length names it: a (tail, part index)
// that failed once fails again however the earlier parts were split.
if failed.contains(&(w.len(), index)) {
return false;
}
if self.compound.is_none() && self.compound_end.is_none() {
return false;
}
@@ -322,10 +330,11 @@ impl Dictionary {
if [self.compound, self.compound_end].into_iter().flatten().any(|f| self.compound_part(rest, f, true)) {
return true;
}
if self.compounded(rest, index + 1) {
if self.compounded(rest, index + 1, failed) {
return true;
}
}
failed.insert((w.len(), index));
false
}
@@ -352,7 +361,7 @@ impl Dictionary {
return true;
}
}
self.compounded(&w, 0)
self.compounded(&w, 0, &mut HashSet::new())
}
/// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass.
@@ -576,6 +585,15 @@ fn cache() -> &'static Cache {
CACHE.get_or_init(Default::default)
}
/// Language strings remembered by [`dictionary`]; a string past it is resolved again on each call.
const MAX_CACHED_LANGS: usize = 256;
/// Parsed dictionaries by their affix file, so aliases of one language share it.
fn by_files() -> &'static Mutex<HashMap<PathBuf, Arc<Dictionary>>> {
static M: OnceLock<Mutex<HashMap<PathBuf, Arc<Dictionary>>>> = OnceLock::new();
M.get_or_init(Default::default)
}
/// The dictionary for `lang` (a BCP-47 tag such as `en-US`), loaded once per process.
pub fn dictionary(lang: &str) -> Option<Arc<Dictionary>> {
let cache = cache();
@@ -586,10 +604,23 @@ pub fn dictionary(lang: &str) -> Option<Arc<Dictionary>> {
return d.clone();
}
let loaded = find_in(&search_dirs(), lang).and_then(|(a, d)| {
let (a, d) = (std::fs::read(a).ok()?, std::fs::read(d).ok()?);
Some(Arc::new(Dictionary::parse(&a, &d)))
// Tags that resolve to the same files share one parsed dictionary.
if let Ok(m) = by_files().lock()
&& let Some(dict) = m.get(&a)
{
return Some(dict.clone());
}
let (bytes_a, bytes_d) = (std::fs::read(&a).ok()?, std::fs::read(d).ok()?);
let dict = Arc::new(Dictionary::parse(&bytes_a, &bytes_d));
// A concurrent loader of the same files may have inserted first: share its copy.
if let Ok(mut m) = by_files().lock() {
return Some(m.entry(a).or_insert(dict).clone());
}
Some(dict)
});
if let Ok(mut c) = cache.lock() {
if let Ok(mut c) = cache.lock()
&& c.len() < MAX_CACHED_LANGS
{
c.insert(key, loaded.clone());
}
loaded
@@ -668,6 +699,8 @@ pub fn add_user_word(word: &str) -> std::io::Result<()> {
return Ok(());
}
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
// A confined automation client may write only inside its write roots, this file included.
crate::file_access::check_write_path(&path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
if let Some(d) = path.parent() {
std::fs::create_dir_all(d)?;
}
@@ -700,16 +733,31 @@ pub struct Word {
pub repeated: Option<usize>,
}
/// Longest language tag a word is checked in. BCP 47 tags in documents are a few bytes; a longer
/// value from a file is no language we have a dictionary for, and copying it into every word of
/// a paragraph would multiply it.
const MAX_LANG_LEN: usize = 64;
/// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words
/// with digits are skipped; apostrophes inside a word belong to it; hyphenated words are checked
/// part by part.
/// part by part. A language over [`MAX_LANG_LEN`] counts as none.
pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Word> {
let mut chars: Vec<(char, usize)> = vec![];
for (ri, r) in p.runs.iter().enumerate() {
chars.extend(r.content().chars().map(|c| (c, ri)));
}
let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string());
let inside = |c: char| c.is_alphanumeric() || c == '\'' || c == '’' || c == '-';
let default = if default.len() > MAX_LANG_LEN { "" } else { default };
let lang_of = |ri: usize| {
p.runs
.get(ri)
.and_then(|r| r.props.lang.as_deref())
.filter(|l| !l.is_empty())
.map(|l| if l.len() > MAX_LANG_LEN { "" } else { l })
.unwrap_or(default)
.to_string()
};
// Combining marks (decomposed accents) belong to the letter before them.
let inside = |c: char| c.is_alphanumeric() || deckcraft_model::words::is_combining_mark(c) || c == '\'' || c == '’' || c == '-';
let mut out = vec![];
let mut i = 0;
while i < chars.len() {
@@ -725,8 +773,14 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Wor
while a < b && !chars[a].0.is_alphanumeric() {
a += 1;
}
while b > a && !chars[b - 1].0.is_alphanumeric() {
b -= 1;
// Trim back to the last letter or digit, keeping only the marks that sit on it
// (a mark on a trailing apostrophe goes with the apostrophe).
while b > a {
let k = (a..b).rev().find(|&k| !deckcraft_model::words::is_combining_mark(chars[k].0)).unwrap_or(a);
if chars[k].0.is_alphanumeric() {
break;
}
b = k;
}
let text: String = chars[a..b].iter().map(|c| c.0).collect();
if text.is_empty() || text.chars().any(|c| c.is_ascii_digit()) || !text.chars().any(char::is_alphabetic) {
@@ -784,15 +838,18 @@ pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String]
.chain(repeated(p, default))
.collect::<Vec<_>>()
.into_iter()
.fold(vec![], |mut v: Vec<Word>, w| {
.fold((vec![], HashMap::new()), |(mut v, mut at): (Vec<Word>, HashMap<usize, usize>), w| {
// A repeated misspelt word is reported once, as repeated.
if let Some(i) = v.iter().position(|x| x.start == w.start) {
v[i] = w;
} else {
v.push(w);
match at.get(&w.start).and_then(|&i| v.get_mut(i)) {
Some(x) => *x = w,
None => {
at.insert(w.start, v.len());
v.push(w);
}
}
v
(v, at)
})
.0
}
/// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself).
@@ -819,6 +876,20 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
Dictionary::parse(AFF.as_bytes(), DIC.as_bytes())
}
#[test]
fn nfd_word_not_split() {
let p = deckcraft_model::Paragraph::new("Das na\u{ef}ve Caf\u{e9} und cafe\u{301} na\u{308}ive");
let w: Vec<String> = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect();
assert_eq!(w, ["Das", "na\u{ef}ve", "Caf\u{e9}", "und", "cafe\u{301}", "na\u{308}ive"]);
}
#[test]
fn a_mark_on_trailing_punctuation_is_trimmed_with_it() {
let p = deckcraft_model::Paragraph::new("word'\u{301} cafe\u{301}' x");
let w: Vec<String> = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect();
assert_eq!(w, ["word", "cafe\u{301}", "x"]);
}
#[test]
fn curly_apostrophe_matches_ascii_entry() {
let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes());
@@ -859,6 +930,14 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string()));
}
/// An affix header can claim billions of records; parsing stops where the file does.
#[test]
fn a_header_count_past_the_records_ends_with_them() {
let aff = b"SFX S Y 4000000000\nSFX S 0 s .\nREP 4000000000\nREP a b\n";
let d = Dictionary::parse(aff, b"1\ncat/S\n".as_slice());
assert!(d.check("cats"));
}
/// #1175: compound splitting is quadratic in the word's length, so a word longer than
/// `MAX_CHECK_CHARS` is not checked; shorter ones still are.
#[test]
@@ -893,6 +972,17 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
}
}
#[test]
fn overlapping_compound_roots_with_a_failing_ending_stay_cheap() {
// Roots a..aaaaaaaa overlap, so many splits of "aaa…ab" reach the same (tail, part) state; unmemoized that is millions of searches.
let d = Dictionary::parse(
b"COMPOUNDFLAG X\nCOMPOUNDMIN 1\n".as_slice(),
b"8\na/X\naa/X\naaa/X\naaaa/X\naaaaa/X\naaaaaa/X\naaaaaaa/X\naaaaaaaa/X\n".as_slice(),
);
assert!(!d.check(&format!("{}b", "a".repeat(99))));
assert!(d.check(&"a".repeat(20)));
}
#[test]
fn runs_give_words_their_language() {
use deckcraft_model::{Paragraph, Run, RunProps};
@@ -932,6 +1022,37 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
assert_eq!(w, vec!["check", "done"]);
}
#[test]
fn a_confined_client_cannot_write_the_user_dictionary_outside_its_roots() {
let root = std::env::temp_dir().join(format!("deckcraft-dict-root-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = crate::file_access::AutomationRoots::new(std::slice::from_ref(&root), std::slice::from_ref(&root)).unwrap();
// The user dictionary is a file of its own outside `root`, not whatever HOME points at.
let outside = std::env::temp_dir().join(format!("deckcraft-dict-outside-{}", std::process::id()));
set_user_dictionary_path(outside.join("user.dic"));
let r = crate::file_access::confine(roots.as_ref(), || add_user_word("zqxconfinedword"));
assert_eq!(r.map_err(|e| e.kind()), Err(std::io::ErrorKind::PermissionDenied));
assert!(!outside.exists(), "the refused write created nothing");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() {
use deckcraft_model::{Paragraph, Run, RunProps};
// 100,000 copies of one word under a 1 MB language value: every word after the first is
// a repeat, and no word carries a copy of the value.
let lang = "x".repeat(1 << 20);
let p =
Paragraph { runs: vec![Run::with("the ".repeat(100_000), RunProps { lang: Some(lang), ..Default::default() })], ..Default::default() };
let words = paragraph_words(&p, "en-US");
assert_eq!(words.len(), 100_000);
assert!(words.iter().all(|w| w.lang.is_empty()));
let bad = misspelt(&p, "en-US", &[]);
assert_eq!(bad.len(), 99_999);
assert!(bad.iter().all(|w| w.repeated.is_some()));
assert!(paragraph_words(&Paragraph::new("word"), &"y".repeat(100)).iter().all(|w| w.lang.is_empty()));
}
#[test]
fn long_and_numeric_flags_and_latin1() {
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -17,6 +17,7 @@ mod geometry;
mod hit;
mod pointer;
pub(crate) use drag::MAX_GESTURE_POINTS;
pub use hit::{line_xfrm, resize_box, table_rows, text_pos};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -150,8 +151,10 @@ enum Drag {
},
Resize {
id: ShapeId,
handle: u8,
orig: Xfrm,
ids: Vec<ShapeId>,
handle: u8,
origs: Vec<(ShapeId, Xfrm)>,
start: Point,
},
LineEnd {
@@ -273,6 +276,8 @@ pub struct ToolState {
pub path_preview: Vec<Point>,
pub path_smooth: bool,
pub path_closing: bool,
/// Whether the last freeform press added a vertex (it adds none at the point cap).
pub press_added_vertex: bool,
/// The shape in Edit Points: its vertices are handles instead of the box's.
pub edit_points: Option<ShapeId>,
/// The vertex (path, index) whose control handles show in Edit Points.
+6 -2
View File
@@ -4,8 +4,12 @@ use super::{Drag, Hit, Point, Result, Session, ShapeId, ShapeKind, TextSel, Tool
impl Session {
pub(super) fn double_click(&mut self, p: Point, tol: f64) -> Result<Value> {
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
// The double-click's own click added a duplicate vertex.
while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) {
// The double-click's own click added a duplicate vertex (none at the point cap, where
// the existing vertices are all kept).
while self.tool.press_added_vertex
&& points.len() > 2
&& points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol)
{
points.pop();
}
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
+71 -11
View File
@@ -12,8 +12,16 @@ struct MoveDrag {
enter_text: Option<(ShapeId, Point)>,
}
/// Samples one ink or freeform gesture keeps; later pointer moves extend it no further.
pub(crate) const MAX_GESTURE_POINTS: usize = 100_000;
impl Session {
/// Update the active drag interaction using the pointer position and modifiers.
pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
if matches!(self.tool.drag, Some(Drag::Ink { .. })) {
self.extend_ink(p);
return Ok(Value::Null);
}
let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
match drag {
Drag::Guide { index } => {
@@ -26,10 +34,48 @@ impl Session {
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol);
}
Drag::Resize { id, handle, orig, start } => {
let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt);
let nx = self.snap_resize(nx, &orig, handle, id, tol);
self.set_xfrm(id, nx)?;
Drag::Resize { id, orig, ids, handle, origs, start } => {
let keep_aspect = mods.shift || self.is_picture(id);
let nx = resize_box(&orig, handle, p - start, keep_aspect, mods.alt);
let nx = self.snap_resize(nx, handle, &ids, tol);
let sx = if orig.w > 1e-9 { (nx.w / orig.w) * if nx.flip_h != orig.flip_h { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
let sy = if orig.h > 1e-9 { (nx.h / orig.h) * if nx.flip_v != orig.flip_v { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
let div = if mods.alt { 2.0 } else { 1.0 };
let hx = match handle {
0 | 6 | 7 => -1.0,
2..=4 => 1.0,
_ => 0.0,
};
let hy = match handle {
0..=2 => -1.0,
4..=6 => 1.0,
_ => 0.0,
};
for (tid, o) in &origs {
let target_nx = if *tid == id {
nx
} else {
let picture = self.is_picture(*tid);
let (sx, sy) = if picture && matches!(handle, 0 | 2 | 4 | 6) {
// Use the axis with the larger relative change so an unchanged
// dimension cannot cancel shrinking. Preserve each axis's flip.
let scale_x = 1.0 + sx;
let scale_y = 1.0 + sy;
let scale = if sx.abs() >= sy.abs() { scale_x.abs() } else { scale_y.abs() };
(scale.copysign(scale_x) - 1.0, scale.copysign(scale_y) - 1.0)
} else {
(sx, sy)
};
let lp = Affine::rotate(o.rot.to_radians())
* Point::new(
hx * sx * o.w / div * if o.flip_h { -1.0 } else { 1.0 },
hy * sy * o.h / div * if o.flip_v { -1.0 } else { 1.0 },
);
resize_box(o, handle, Vec2::new(lp.x, lp.y), picture, mods.alt)
};
self.set_xfrm(*tid, target_nx)?;
}
}
Drag::LineEnd { id, end, orig } => {
let a = orig.affine();
@@ -142,7 +188,7 @@ impl Session {
}
Drag::Freeform { mode, mut points, pressed } => {
// Dragging scribbles (except for curves, which only take clicked points).
if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
if pressed && mode != "curve" && points.len() < MAX_GESTURE_POINTS && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
points.push(p);
}
let mut v = points.clone();
@@ -152,15 +198,24 @@ impl Session {
self.tool.path_preview = v;
self.tool.drag = Some(Drag::Freeform { mode, points, pressed });
}
Drag::Ink { mut points } => {
points.push(self.ink_sample(p));
self.tool.ink_preview = points.clone();
self.tool.drag = Some(Drag::Ink { points });
}
// Handled above.
Drag::Ink { .. } => {}
}
Ok(Value::Null)
}
/// Ink grows by one sample per move: extend it (and its preview) in place rather than
/// copying the whole stroke twice per event, up to [`MAX_GESTURE_POINTS`].
fn extend_ink(&mut self, p: Point) {
let sample = self.ink_sample(p);
if let Some(Drag::Ink { points }) = self.tool.drag.as_mut()
&& points.len() < MAX_GESTURE_POINTS
{
points.push(sample);
self.tool.ink_preview.push(sample);
}
}
fn drag_move(&mut self, drag: MoveDrag, p: Point, mods: Mods, tol: f64) -> Result<Value> {
let MoveDrag { ids, start, origs, moved, duplicate, enter_text } = drag;
let mut d = p - start;
@@ -197,7 +252,12 @@ impl Session {
}
}
// A connector dragged away from shapes that stay put comes unglued at those ends.
let moved: Vec<ShapeId> = origs2.iter().map(|o| o.0).collect();
let mut moved: Vec<ShapeId> = vec![];
for (id, _) in &origs2 {
if let Some(sh) = deckcraft_model::find_shape(list, *id) {
deckcraft_model::walk(std::slice::from_ref(sh), &mut |s, _| moved.push(s.id));
}
}
for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) {
if let ShapeKind::Connector { start, end } = &mut sh.kind {
for c in [start, end] {
+3 -2
View File
@@ -101,6 +101,7 @@ impl Session {
let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default();
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
let boxes: Vec<Xfrm> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
cmd::edit::detach_on_slide(doc, sel, &mut copies);
cmd::edit::reid("edit.duplicate", doc, &mut copies)?;
for (c, b) in copies.iter_mut().zip(boxes) {
c.xfrm = Some(b);
@@ -228,7 +229,7 @@ impl Session {
}
/// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides.
pub(super) fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {
pub(super) fn snap_resize(&mut self, x: Xfrm, handle: u8, exclude: &[ShapeId], tol: f64) -> Xfrm {
if x.rot != 0.0 {
self.tool.guides.clear();
return x;
@@ -241,7 +242,7 @@ impl Session {
let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6));
let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) };
let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) };
let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
let (off, guides) = self.snap_edges(r, exclude, tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
let mut nx = x;
if moves_left {
nx.x += off.x;
+6 -2
View File
@@ -216,6 +216,7 @@ pub(super) fn in_table_cell(st: &crate::DocState, id: ShapeId, p: Point) -> bool
})
}
/// Resolve a shape's target at slide point `p`, using its effective fill for interior hits.
pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit> {
let x = xfrm_of(&st.doc, &st.selection, sh);
let inv = x.affine().inverse();
@@ -267,8 +268,11 @@ pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) ->
return None;
}
let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box);
// Empty interiors of unfilled, text-less shapes don't catch clicks.
let filled = !matches!(sh.fill, Some(deckcraft_model::Fill::None)) || sh.style.is_some();
// Match rendering: an explicit No Fill overrides the style, and style/theme fills
// can themselves be No Fill. Empty, text-less interiors then let clicks through.
let fill =
cmd::text::ctx_for(&st.doc, &st.selection).map(|ctx| deckcraft_model::resolve::fill(&ctx, sh).0).unwrap_or_else(|| sh.fill.clone());
let filled = !matches!(fill, None | Some(deckcraft_model::Fill::None));
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
let in_geo = {
use deckcraft_geom::KurboShape;
+26 -5
View File
@@ -220,6 +220,7 @@ impl Session {
}
}
/// Handle a pointer press and start the applicable tool interaction.
fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
// Next click of a freeform: close on the start point, else add a vertex.
@@ -227,7 +228,10 @@ impl Session {
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
return self.finish_freeform(true);
}
points.push(p);
self.tool.press_added_vertex = points.len() < super::MAX_GESTURE_POINTS;
if self.tool.press_added_vertex {
points.push(p);
}
self.tool.path_preview = points.clone();
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true });
return Ok(Value::Null);
@@ -303,8 +307,12 @@ impl Session {
Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?,
Some(Hit::Resize { id, handle }) => {
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
let ids: Vec<ShapeId> = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] };
let origs: Vec<(ShapeId, Xfrm)> =
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
let ids: Vec<ShapeId> = origs.iter().map(|(id, _)| *id).collect();
self.begin("Size");
self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p });
self.tool.drag = Some(Drag::Resize { id, orig, ids, handle, origs, start: p });
}
Some(Hit::LineEnd { id, end }) => {
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
@@ -436,6 +444,7 @@ impl Session {
Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()}))
}
/// Handle a pointer release and finalize the active tool interaction.
fn up(&mut self, p: Point, mods: Mods) -> Result<Value> {
let drag = self.tool.drag.take();
self.tool.guides.clear();
@@ -558,10 +567,22 @@ impl Session {
out = self.add_ink(points)?;
}
}
Some(Drag::Resize { id, .. }) => {
Some(Drag::Resize { origs, .. }) => {
let changed: Vec<ShapeId> = origs
.iter()
.filter_map(|(id, orig)| {
let current = self.active().and_then(|st| st.shape(*id).map(|s| xfrm_of(&st.doc, &st.selection, s)))?;
(current != *orig).then_some(*id)
})
.collect();
if !changed.is_empty() {
for id in changed {
let _ = cmd::text::fit_text_box(self, id);
self.refit_after_resize(id)?;
}
}
self.commit();
let _ = cmd::text::fit_text_box(self, id);
self.refit_after_resize(id)?;
}
Some(Drag::TextSelect { before, .. }) => {
out = self.paint_if_selection_changed(before, out)?;
+31 -3
View File
@@ -169,8 +169,14 @@ impl FontDb {
if bundled.contains(fam) {
continue;
}
if self.is_loaded(fam) || self.has_family(fam) {
let f = self.face(fam, style);
if self.is_loaded(fam) || (self.system_fallback_enabled() && self.has_family(fam)) {
// With system fallback off, only a face that is already loaded: `face` could load more catalog files.
let f = if self.system_fallback_enabled() {
self.face(fam, style)
} else {
let Some(f) = self.find(fam, style) else { continue };
f
};
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
return f;
}
@@ -227,6 +233,9 @@ impl FontDb {
}
}
/// Most caret records read for one ligature glyph.
const MAX_LIGATURE_CARETS: usize = 64;
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
@@ -236,9 +245,12 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
let Ok(cov) = list.coverage() else { return Vec::new() };
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
// The limit applies to the records read, not to the carets kept: a font can list thousands of
// records it then has to be read through for every ligature laid out.
let mut out: Vec<i32> = lig
.caret_values()
.iter()
.take(MAX_LIGATURE_CARETS)
.filter_map(|cv| cv.ok())
.filter_map(|cv| match cv {
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
@@ -246,7 +258,6 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
// Format 2 is a contour point index; outlines aren't consulted here.
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
})
.take(64)
.collect();
out.sort_unstable();
out
@@ -306,4 +317,21 @@ mod tests {
assert!(c.windows(2).all(|w| w[0] <= w[1]));
}
}
/// With system fallback off, the automatic cascades (script families and symbol faces) load no
/// installed family: the result comes from what is already loaded.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn cascades_load_no_installed_family_when_system_fallback_is_off() {
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
let base = db.face(crate::DEFAULT_FAMILY, "Regular");
let loaded = |db: &FontDb| [b"Arab", b"Hebr", b"Thai", b"Deva"].into_iter().flat_map(|s| os_families(*s)).filter(|f| db.is_loaded(f)).count();
let before = loaded(&db);
for c in ['\u{0628}', '\u{05D0}', '\u{0E01}', '\u{0905}', '\u{2211}', '\u{2603}'] {
let f = db.cascade(&[c], std::slice::from_ref(&base), "Regular");
assert!(!f.family.is_empty());
}
assert_eq!(loaded(&db), before, "no installed family was loaded");
}
}
+238 -25
View File
@@ -30,12 +30,15 @@ pub fn bundled() -> Vec<&'static [u8]> {
/// Open substitutes for families documents often ask for but we can't ship (metric-compatible
/// where one exists), tried in order after the family itself.
pub fn substitutes(family: &str) -> &'static [&'static str] {
match family.to_ascii_lowercase().as_str() {
let f = family.to_ascii_lowercase();
if is_monospace_family(&f) {
return &["Liberation Mono", "Courier New", "Menlo", "Courier", "DejaVu Sans Mono"];
}
match f.as_str() {
"calibri" | "calibri light" => &["Carlito", "Liberation Sans", "Arial", "Inter"],
"cambria" | "cambria math" => &["Caladea", "Liberation Serif", "Times New Roman", "Merriweather"],
"arial" | "helvetica" | "helvetica neue" | "arial nova" => &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"],
"times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times", "Merriweather"],
"courier new" | "courier" | "consolas" | "menlo" => &["Liberation Mono", "Courier New", "Menlo", "Courier"],
"aptos" | "aptos display" | "aptos narrow" | "segoe ui" | "segoe ui light" | "segoe ui semibold" | "tahoma" | "verdana" => {
&["Inter", "Helvetica Neue", "Arial", "Liberation Sans"]
}
@@ -52,12 +55,77 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {
}
}
/// Families that are monospaced: when not installed, documents keep a monospaced substitute
/// ([`substitutes`]) so code blocks and columns stay aligned instead of falling back to
/// proportional Calibri.
pub fn is_monospace_family(family: &str) -> bool {
let f = family.to_ascii_lowercase();
let s = f.trim();
if matches!(
s,
"courier new"
| "courier"
| "cousine"
| "consolas"
| "menlo"
| "monaco"
| "source code pro"
| "jetbrains mono"
| "cascadia code"
| "cascadia code pl"
| "cascadia mono"
| "cascadia mono pl"
| "fira code"
| "fira mono"
| "inconsolata"
| "dejavu sans mono"
| "sf mono"
| "roboto mono"
| "hack"
| "ubuntu mono"
| "pt mono"
| "liberation mono"
| "andale mono"
| "lucida console"
| "lucida sans typewriter"
| "fixedsys"
| "terminal"
| "monospace"
| "iosevka"
| "monolisa"
| "envy code r"
| "hasklig"
) {
return true;
}
s.starts_with("courier")
|| s.starts_with("consolas")
|| s.starts_with("inconsolata")
|| s.starts_with("iosevka")
|| s.ends_with(" mono")
|| s.ends_with(" code")
|| s.ends_with(" console")
|| s.ends_with(" typewriter")
|| s.ends_with(" monospace")
|| s.split_whitespace().any(|w| w == "mono" || w == "terminal" || w == "monospace")
}
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
/// them breaks into the same lines.
fn has_metric_twin(family: &str) -> bool {
matches!(
family.to_ascii_lowercase().as_str(),
"calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"
"calibri"
| "calibri light"
| "cambria"
| "cambria math"
| "arial"
| "helvetica"
| "times new roman"
| "times"
| "courier new"
| "courier"
| "cousine"
)
}
@@ -300,7 +368,7 @@ struct CatalogEntry {
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
outlines: Mutex<OutlineCache>,
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock<Vec<CatalogEntry>>,
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
@@ -371,6 +439,38 @@ const SYSTEM_FALLBACKS: &[&str] = &[
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
const OUTLINE_CACHE_MAX: usize = 50_000;
/// Most path elements one glyph outline may expand to. Composite and `VARC` glyphs can draw a
/// shared outline many times over; a glyph past this draws nothing rather than holding megabytes.
const MAX_OUTLINE_ELEMENTS: usize = 1 << 15;
/// Most path elements the outline cache holds across all glyphs (about 230 MB at most) before it
/// starts over: distinct glyph ids can each expand one dense outline.
const OUTLINE_CACHE_MAX_ELEMENTS: usize = 1 << 22;
/// Glyph outlines by (face, glyph), with the path elements they hold.
#[derive(Default)]
struct OutlineCache {
paths: HashMap<(u32, u32), Arc<BezPath>>,
elements: usize,
}
impl OutlineCache {
fn get(&self, key: &(u32, u32)) -> Option<Arc<BezPath>> {
self.paths.get(key).cloned()
}
fn insert(&mut self, key: (u32, u32), path: Arc<BezPath>) {
let n = path.elements().len();
if let Some(old) = self.paths.remove(&key) {
self.elements = self.elements.saturating_sub(old.elements().len());
}
if self.paths.len() >= OUTLINE_CACHE_MAX || self.elements.saturating_add(n) > OUTLINE_CACHE_MAX_ELEMENTS {
self.paths.clear();
self.elements = 0;
}
self.paths.insert(key, path);
self.elements = self.elements.saturating_add(n);
}
}
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
@@ -626,6 +726,10 @@ const MAX_COLLECTION_FACES: u32 = 256;
const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384;
/// Maximum named instances read from a single face.
const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024;
/// Maximum faces (named instances counted separately) enumerated from one font file, across all
/// of a collection's faces: the per-collection and per-face limits multiply, and each face found
/// is then built with its own shaper.
const MAX_ENUMERATED_FACES: usize = 4096;
fn distinct_face_indices(entries: impl IntoIterator<Item = (u32, u32)>) -> Vec<u32> {
let mut offsets = std::collections::HashSet::new();
@@ -662,21 +766,30 @@ fn enumerate_faces(data: &[u8]) -> Vec<Found> {
Err(_) => Vec::new(),
};
let mut out = Vec::new();
// Named instances read and plain faces kept, across the whole file.
let mut budget = MAX_ENUMERATED_FACES;
for i in indices {
if budget == 0 {
break;
}
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
let Some((family, style)) = face_names(&f) else { continue };
let axes = f.axes();
let mut named = Vec::new();
let mut seen = std::collections::HashSet::new();
let instances = f.named_instances();
for ni in instances.iter().take(capped_named_instance_count(instances.len())) {
let Some(left) = budget.checked_sub(1) else { break };
budget = left;
let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue };
if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) {
if !seen.insert(style.to_ascii_lowercase()) {
continue;
}
let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect();
named.push((i, style, coords));
}
if named.is_empty() {
budget = budget.saturating_sub(1);
out.push((i, family, style, Vec::new()));
} else {
out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c)));
@@ -837,7 +950,7 @@ impl FontDb {
}
Self {
faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()),
outlines: Mutex::new(OutlineCache::default()),
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock::new(Vec::new()),
font_dirs,
@@ -863,6 +976,17 @@ impl FontDb {
let _ = on;
}
/// Whether automatic system-font lookups (fallback by character, symbol and script families)
/// may touch installed fonts. Lookups of a family the document or host names are not gated.
pub(crate) fn system_fallback_enabled(&self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled
}
#[cfg(target_arch = "wasm32")]
false
}
/// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the
/// first time a lookup by family name needs them (or ahead of time by
/// [`FontDb::scan_in_background`]).
@@ -1080,6 +1204,7 @@ impl FontDb {
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
/// Monospaced families keep a monospaced substitute instead of falling back to Calibri.
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> {
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
@@ -1088,6 +1213,9 @@ impl FontDb {
if let Some(twin) = office_twin(family) {
return self.face(twin, style);
}
if is_monospace_family(family) {
return self.face(family, style);
}
self.face(MISSING_FONT_FAMILY, style)
}
@@ -1143,9 +1271,8 @@ impl FontDb {
fn instance(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
let open = style.find('{')?;
let base = self.find(family, style[..open].trim())?;
if base.skrifa().is_none_or(|f| f.axes().is_empty()) {
return Some(base);
}
let Some(font) = base.skrifa().filter(|f| !f.axes().is_empty()) else { return Some(base) };
let axis_tags: Vec<[u8; 4]> = font.axes().iter().map(|a| a.tag().to_be_bytes()).collect();
let mut coords = base.coords.clone();
for kv in style[open + 1..].trim_end_matches('}').split(',') {
let (k, v) = kv.split_once(':')?;
@@ -1155,6 +1282,11 @@ impl FontDb {
return None;
}
let tag = [k[0], k[1], k[2], k[3]];
// A tag the font has no axis for changes nothing; keeping it would grow the list with
// every distinct tag in the style text.
if !axis_tags.contains(&tag) {
continue;
}
match coords.iter_mut().find(|(t, _)| *t == tag) {
Some(c) => c.1 = v,
None => coords.push((tag, v)),
@@ -1166,7 +1298,7 @@ impl FontDb {
Some(f)
}
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
pub(crate) fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
if style.contains('{') {
{
let faces = self.read_faces();
@@ -1352,7 +1484,8 @@ impl FontDb {
};
#[cfg(not(target_arch = "wasm32"))]
{
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
let cataloged: Vec<String> =
if self.system_fallback_enabled() { self.read_catalog().iter().map(|e| e.family.clone()).collect() } else { Vec::new() };
for fam in SYSTEM_FALLBACKS.iter().filter(|f| f.contains("Symbol")) {
if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
self.load_cataloged(fam);
@@ -1441,41 +1574,72 @@ impl FontDb {
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
return p;
}
let mut pen = FlipPen(BezPath::new());
let mut pen = FlipPen::new(MAX_OUTLINE_ELEMENTS);
if let Some(f) = face.skrifa()
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
}
let p = Arc::new(pen.0);
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
cache.insert(key, p.clone());
let p = Arc::new(pen.finish());
self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone());
p
}
}
struct FlipPen(BezPath);
/// Collects a glyph outline, y flipped, up to `limit` path elements. Past the limit it ignores
/// what follows and [`FlipPen::finish`] gives an empty path: a cut-off outline would fill wrongly.
struct FlipPen {
path: BezPath,
limit: usize,
over: bool,
}
impl FlipPen {
fn new(limit: usize) -> Self {
FlipPen { path: BezPath::new(), limit, over: false }
}
/// Room for one more element?
fn room(&mut self) -> bool {
if !self.over && self.path.elements().len() >= self.limit {
self.over = true;
self.path = BezPath::new();
}
!self.over
}
fn finish(self) -> BezPath {
if self.over { BezPath::new() } else { self.path }
}
}
impl OutlinePen for FlipPen {
fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to((x as f64, -y as f64));
if self.room() {
self.path.move_to((x as f64, -y as f64));
}
}
fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to((x as f64, -y as f64));
if self.room() {
self.path.line_to((x as f64, -y as f64));
}
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
if self.room() {
self.path.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
}
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
if self.room() {
self.path.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
}
}
fn close(&mut self) {
self.0.close_path();
if self.room() {
self.path.close_path();
}
}
}
@@ -1503,6 +1667,55 @@ mod font_dir_tests {
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE);
}
#[test]
fn faces_of_one_file_are_bounded_across_the_collection() {
// The per-collection and per-face limits together would allow far more.
assert!((MAX_COLLECTION_FACES as usize) * MAX_NAMED_INSTANCES_PER_FACE > 16 * MAX_ENUMERATED_FACES);
for data in bundled() {
assert!(enumerate_faces(data).len() <= MAX_ENUMERATED_FACES);
}
}
#[test]
fn an_outline_past_the_element_limit_draws_nothing() {
let mut pen = FlipPen::new(8);
pen.move_to(0.0, 0.0);
pen.line_to(1.0, 1.0);
pen.close();
assert_eq!(pen.finish().elements().len(), 3);
// A shared outline drawn over and over (composite and VARC glyphs).
let mut pen = FlipPen::new(8);
for i in 0..1_000_000 {
pen.move_to(0.0, 0.0);
pen.line_to(i as f32, 1.0);
pen.close();
}
assert_eq!(pen.path.elements().len(), 0);
assert!(pen.finish().elements().is_empty());
}
#[test]
fn outline_cache_is_bounded_by_elements() {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
for i in 1..MAX_OUTLINE_ELEMENTS {
path.line_to((i as f64, 0.0));
}
let path = Arc::new(path);
let mut cache = OutlineCache::default();
let glyphs = 4 * OUTLINE_CACHE_MAX_ELEMENTS / MAX_OUTLINE_ELEMENTS;
for gid in 0..glyphs as u32 {
cache.insert((1, gid), path.clone());
assert!(cache.elements <= OUTLINE_CACHE_MAX_ELEMENTS);
assert_eq!(cache.elements, cache.paths.values().map(|p| p.elements().len()).sum::<usize>());
}
assert!(cache.get(&(1, glyphs as u32 - 1)).is_some());
// Replacing an entry does not count it twice.
let before = cache.elements;
cache.insert((1, glyphs as u32 - 1), path.clone());
assert_eq!(cache.elements, before);
}
#[test]
fn xdg_data_dirs_contribute_their_fonts_folders() {
let home = Path::new("/home/ann");
+2 -1
View File
@@ -15,7 +15,8 @@ mod shape;
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
pub use fontdb::{
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, is_monospace_family, substitutes,
system_font_dirs,
};
pub use harfrust::Feature;
use harfrust::Tag;

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