Compare commits

...
87 Commits
Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
Harrison Booth 98138f563b Trim whitespace in bullet point sizes 2026-10-30 23:24:07 -05:00
Harrison Booth 917d4b96a9 Honor bullet size schema bounds and inheritance 2026-10-30 23:07:54 -05:00
Harrison Booth b1900e526d Preserve point-sized bullets in PPTX 2026-10-30 22:42:28 -05: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
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
DominicTheD3v e812da58df fix: addressing issues 2026-10-30 22:51:33 +00:00
DominicTheD3v 82adf7326e fix: address raised issues 2026-10-30 21:13:44 +00: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
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
71 changed files with 3459 additions and 286 deletions
Generated
+28 -28
View File
@@ -984,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]] [[package]]
name = "deckcraft" name = "deckcraft"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -1006,7 +1006,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-anim" name = "deckcraft-anim"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"deckcraft-geom", "deckcraft-geom",
"deckcraft-model", "deckcraft-model",
@@ -1015,7 +1015,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-av1" name = "deckcraft-av1"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"deckcraft-bitstream", "deckcraft-bitstream",
"rayon", "rayon",
@@ -1024,14 +1024,14 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-bitstream" name = "deckcraft-bitstream"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"craft-media", "craft-media",
] ]
[[package]] [[package]]
name = "deckcraft-cli" name = "deckcraft-cli"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"deckcraft-engine", "deckcraft-engine",
"deckcraft-mcp", "deckcraft-mcp",
@@ -1041,14 +1041,14 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-color" name = "deckcraft-color"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"serde", "serde",
] ]
[[package]] [[package]]
name = "deckcraft-engine" name = "deckcraft-engine"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"deckcraft-anim", "deckcraft-anim",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-fonts" name = "deckcraft-fonts"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"epaint_default_fonts", "epaint_default_fonts",
"harfrust 0.13.3", "harfrust 0.13.3",
@@ -1086,7 +1086,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-format" name = "deckcraft-format"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"deckcraft-model", "deckcraft-model",
"serde", "serde",
@@ -1097,7 +1097,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-geom" name = "deckcraft-geom"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"kurbo", "kurbo",
"serde", "serde",
@@ -1105,7 +1105,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-h264" name = "deckcraft-h264"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"deckcraft-bitstream", "deckcraft-bitstream",
"rayon", "rayon",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-hevc" name = "deckcraft-hevc"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"deckcraft-bitstream", "deckcraft-bitstream",
"rayon", "rayon",
@@ -1123,21 +1123,21 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-isobmff" name = "deckcraft-isobmff"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"craft-media", "craft-media",
] ]
[[package]] [[package]]
name = "deckcraft-matroska" name = "deckcraft-matroska"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"craft-media", "craft-media",
] ]
[[package]] [[package]]
name = "deckcraft-mcp" name = "deckcraft-mcp"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"deckcraft-engine", "deckcraft-engine",
"deckcraft-render", "deckcraft-render",
@@ -1147,7 +1147,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-media" name = "deckcraft-media"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"deckcraft-av1", "deckcraft-av1",
"deckcraft-h264", "deckcraft-h264",
@@ -1165,7 +1165,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-metafile" name = "deckcraft-metafile"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"image", "image",
"kurbo", "kurbo",
@@ -1174,7 +1174,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-model" name = "deckcraft-model"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"deckcraft-color", "deckcraft-color",
"deckcraft-geom", "deckcraft-geom",
@@ -1186,15 +1186,15 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-ogg" name = "deckcraft-ogg"
version = "0.14.1" version = "0.15.0"
[[package]] [[package]]
name = "deckcraft-opus" name = "deckcraft-opus"
version = "0.14.1" version = "0.15.0"
[[package]] [[package]]
name = "deckcraft-pdf" name = "deckcraft-pdf"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"craft-pdf", "craft-pdf",
"deckcraft-fonts", "deckcraft-fonts",
@@ -1212,7 +1212,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-pptx" name = "deckcraft-pptx"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"deckcraft-color", "deckcraft-color",
"deckcraft-engine", "deckcraft-engine",
@@ -1228,7 +1228,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-render" name = "deckcraft-render"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"deckcraft-anim", "deckcraft-anim",
"deckcraft-color", "deckcraft-color",
@@ -1247,7 +1247,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-text" name = "deckcraft-text"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"deckcraft-color", "deckcraft-color",
@@ -1261,7 +1261,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-ui-egui" name = "deckcraft-ui-egui"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"craft-ui", "craft-ui",
"craft-update", "craft-update",
@@ -1286,7 +1286,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-vp9" name = "deckcraft-vp9"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"deckcraft-bitstream", "deckcraft-bitstream",
"rayon", "rayon",
@@ -1295,7 +1295,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-web" name = "deckcraft-web"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"deckcraft-engine", "deckcraft-engine",
"deckcraft-ui-egui", "deckcraft-ui-egui",
@@ -6177,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.14.1" version = "0.15.0"
dependencies = [ dependencies = [
"serde_json", "serde_json",
] ]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"] default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package] [workspace.package]
version = "0.14.1" version = "0.15.0"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.95" rust-version = "1.95"
+8 -1
View File
@@ -2,7 +2,8 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away **Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (print-preview tests use egui's emitted URL, and macOS removes its previews before exit; 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 (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-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-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-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)
@@ -148,6 +149,9 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed | | Date | What landed |
|---|---| |---|---|
| 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences |
| 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-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 | 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-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-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 |
@@ -318,6 +322,9 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 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-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-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: 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-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
+1 -1
View File
@@ -27,4 +27,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
js-sys = "0.3" 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"] }
+2
View File
@@ -18,6 +18,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#[cfg(target_arch = "wasm32")]
mod presenter;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
mod web; mod web;
+303
View File
@@ -0,0 +1,303 @@
//! 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();
}
}
*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());
}
}
}
+17
View File
@@ -39,6 +39,10 @@ pub fn start() {
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend); 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); 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 inbox: Inbox = Inbox::default();
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let q = query(); let q = query();
@@ -80,6 +84,11 @@ impl eframe::App for WebShell {
/// Delegate one eframe logic pass to the wrapped application. /// Delegate one eframe logic pass to the wrapped application.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.app.logic(ctx); self.app.logic(ctx);
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, /// Consume browser drops with fresh input, capture the identity before the asynchronous read,
@@ -118,7 +127,15 @@ impl eframe::App for WebShell {
/// back to `open_async` (browser picker → inbox) and `download`. /// back to `open_async` (browser picker → inbox) and `download`.
fn services(inbox: Inbox, ctx: egui::Context) -> Services { fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone(); 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 { Services {
presenter,
open_async: Some(Box::new(move |purpose: &str, request: u64| { open_async: Some(Box::new(move |purpose: &str, request: u64| {
let inbox = open_inbox.clone(); let inbox = open_inbox.clone();
let ctx = ctx.clone(); let ctx = ctx.clone();
+59 -29
View File
@@ -1,5 +1,7 @@
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames. //! 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::resolve::{self, Ctx};
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk}; 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 /// 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 /// 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). /// 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)> { pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new(); let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
let mut used_a = vec![false; a.shapes.len()]; let mut used_a = vec![false; a.shapes.len()];
let mut used_b = vec![false; b.shapes.len()]; let mut used_b = vec![false; b.shapes.len()];
let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [ // Each shape's text, worked out once rather than inside every comparison.
&|x, y| x.name.starts_with("!!") && x.name == y.name, let text_a: Vec<String> = a.shapes.iter().map(text_of).collect();
&|x, y| !x.name.is_empty() && x.name == y.name, let text_b: Vec<String> = b.shapes.iter().map(text_of).collect();
&|x, y| { let forced = |s: &Shape, _: &str| (s.name.starts_with("!!")).then(|| MorphKey::Name(s.name.clone()));
if x.kind_name() != y.kind_name() { let named = |s: &Shape, _: &str| (!s.name.is_empty()).then(|| MorphKey::Name(s.name.clone()));
return false; 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); if let Some(k) = key(sb, text_b.get(j).map_or("", String::as_str)) {
!tx.trim().is_empty() && tx == text_of(y) index.entry(k).or_default().push_back(j);
}, }
&|x, y| { }
x.kind_name() == y.kind_name() if index.is_empty() {
&& x.geom.preset_name().is_some() continue;
&& x.geom.preset_name() == y.geom.preset_name() }
&& text_of(x).trim().is_empty()
&& text_of(y).trim().is_empty()
},
];
for pass in passes {
for (i, sa) in a.shapes.iter().enumerate() { for (i, sa) in a.shapes.iter().enumerate() {
if used_a.get(i).copied().unwrap_or(true) { if used_a.get(i).copied().unwrap_or(true) {
continue; continue;
} }
let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb)); let Some(k) = key(sa, text_a.get(i).map_or("", String::as_str)) else { continue };
if let Some((j, sb)) = found { let Some(j) = index.get_mut(&k).and_then(VecDeque::pop_front) else { continue };
if let Some(u) = used_a.get_mut(i) { let Some(sb) = b.shapes.get(j) else { continue };
*u = true; 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));
} }
if let Some(u) = used_b.get_mut(j) {
*u = true;
}
pairs.push((sa.id, sb.id));
} }
} }
pairs 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 { fn lerp(a: f64, b: f64, t: f64) -> f64 {
let v = a + (b - a) * t; let v = a + (b - a) * t;
if v.is_finite() { 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)); f.shapes.push((sh, old));
id 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 { 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); push(sa.clone(), true, 1.0 - s);
} }
} }
for sb in &b.shapes { 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 { let Some(sa) = pair else {
if !hidden_b(sb.id) { if !hidden_b(sb.id) {
push(sb.clone(), false, s); 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.effects().len(), 6_000);
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]); 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: /// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent:
/// (shape, member paragraph, owner paragraph). /// (shape, member paragraph, owner paragraph).
groups: Vec<(ShapeId, usize, usize)>, 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 { enum Status {
@@ -324,6 +359,7 @@ impl Timeline {
}; };
tl.colors.push((id, c)); tl.colors.push((id, c));
} }
tl.index = TimelineIndex::build(&tl.effects, &tl.groups, &tl.colors);
tl tl
} }
@@ -413,8 +449,12 @@ impl Timeline {
/// Paragraph indices of `shape` animated separately (by-paragraph builds). /// Paragraph indices of `shape` animated separately (by-paragraph builds).
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec<usize> { 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(); let mine = self.index.by_shape.get(&shape).map_or(&[][..], Vec::as_slice);
v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::<Vec<_>>()); 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.sort_unstable();
v.dedup(); v.dedup();
v v
@@ -422,13 +462,8 @@ impl Timeline {
/// Shapes with any effect. /// Shapes with any effect.
pub fn animated_shapes(&self) -> Vec<ShapeId> { pub fn animated_shapes(&self) -> Vec<ShapeId> {
let mut v: Vec<ShapeId> = Vec::new(); let mut seen: HashSet<ShapeId> = HashSet::new();
for e in &self.effects { self.effects.iter().map(|e| e.timing.shape).filter(|s| seen.insert(*s)).collect()
if !v.contains(&e.timing.shape) {
v.push(e.timing.shape);
}
}
v
} }
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been /// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
@@ -456,9 +491,9 @@ impl Timeline {
triggers: &[TriggerPlay], triggers: &[TriggerPlay],
stamps: &[StepStamp], stamps: &[StepStamp],
) -> AnimState { ) -> 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 para = para.map(|p| self.index.owner.get(&(shape, p)).copied().unwrap_or(p));
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para); 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.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82)); let base_color = self.index.color.get(&shape).copied().unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let mut st = AnimState::default(); let mut st = AnimState::default();
// Initial visibility comes from the first effect in the Animation Pane order. // Initial visibility comes from the first effect in the Animation Pane order.
let mut ordered: Vec<&Effect> = mine.collect(); let mut ordered: Vec<&Effect> = mine.collect();
+3 -3
View File
@@ -1,17 +1,17 @@
use super::{ use super::{
Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json, 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> { pub(super) fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?; 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 // A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects
// that layout. With neither key, follow the current slide's layout. // that layout. With neither key, follow the current slide's layout.
let layout = if p.get("master").is_some() && p.get("layout").is_none() { let layout = if p.get("master").is_some() && p.get("layout").is_none() {
None None
} else { } 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)))) .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 { let target = match layout {
@@ -1,12 +1,14 @@
use super::{ use super::{
Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value, 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> { pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
if bool_or(p, "copy", false) { if bool_or(p, "copy", false) {
return copy_slide_background(s); 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 props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
let transparency = f64_param(p, "transparency"); let transparency = f64_param(p, "transparency");
let reset = bool_or(p, "reset", false); 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 all = bool_or(p, "all", false);
let index = usize_param(p, "index");
if matches!(op, BgOp::Adjust) { if matches!(op, BgOp::Adjust) {
let next = { let next = {
let st = s.doc()?; 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(); s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op"); 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);
}
}
+8 -1
View File
@@ -345,6 +345,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()), "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())), "pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
"gif" => gif_bytes(doc, &Value::Null), "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())), _ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
} }
} }
@@ -359,7 +360,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() }; let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
o.layout = match str_param(p, "layout").unwrap_or("slides") { o.layout = match str_param(p, "layout").unwrap_or("slides") {
"notes" => deckcraft_pdf::PageLayout::Notes, "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, _ => deckcraft_pdf::PageLayout::Slides,
}; };
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0); o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
@@ -55,6 +55,7 @@ pub(super) fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
} }
if size.is_some() { if size.is_some() {
para.props.bullet_size = size; para.props.bullet_size = size;
para.props.bullet_size_points = None;
para.props.bullet_size_follow_text = 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) { if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
@@ -85,6 +86,7 @@ pub(super) fn numbering(s: &mut Session, p: &Value) -> Result<Value> {
} }
if size.is_some() { if size.is_some() {
para.props.bullet_size = size; para.props.bullet_size = size;
para.props.bullet_size_points = None;
para.props.bullet_size_follow_text = None; para.props.bullet_size_follow_text = None;
} }
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64); para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
+1 -1
View File
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Fill", "Shape Fill",
["Shape Format", "Shape Styles"], ["Shape Format", "Shape Styles"],
None, 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} | 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, has_selection,
fill fill
), ),
+17 -2
View File
@@ -41,12 +41,13 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
Ok(canonical) => canonical, Ok(canonical) => canonical,
Err(_) => std::path::PathBuf::from(path), 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 existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions());
let mut attempts = 0; let mut attempts = 0;
let (tmp, mut file) = loop { 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);
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(f) => break (tmp, f), Ok(f) => break (tmp, f),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1, Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
@@ -120,4 +121,18 @@ mod tests {
let _ = std::fs::remove_file(&test_file); let _ = std::fs::remove_file(&test_file);
let _ = std::fs::remove_dir(&tmp_dir); 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);
}
} }
+10
View File
@@ -259,6 +259,16 @@ mod tests {
dir 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] #[test]
fn recovers_unsaved_presentations() { fn recovers_unsaved_presentations() {
let dir = temp("recovery"); let dir = temp("recovery");
+24 -3
View File
@@ -709,7 +709,8 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Wor
chars.extend(r.content().chars().map(|c| (c, ri))); 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 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 == '-'; // 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 out = vec![];
let mut i = 0; let mut i = 0;
while i < chars.len() { while i < chars.len() {
@@ -725,8 +726,14 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Wor
while a < b && !chars[a].0.is_alphanumeric() { while a < b && !chars[a].0.is_alphanumeric() {
a += 1; a += 1;
} }
while b > a && !chars[b - 1].0.is_alphanumeric() { // Trim back to the last letter or digit, keeping only the marks that sit on it
b -= 1; // (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(); 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) { if text.is_empty() || text.chars().any(|c| c.is_ascii_digit()) || !text.chars().any(char::is_alphabetic) {
@@ -819,6 +826,20 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
Dictionary::parse(AFF.as_bytes(), DIC.as_bytes()) 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] #[test]
fn curly_apostrophe_matches_ascii_entry() { fn curly_apostrophe_matches_ascii_entry() {
let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes()); let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes());
+441
View File
@@ -2410,6 +2410,23 @@ fn pdf_options_take_paper_and_orientation() {
assert!(cmd::file::pdf_options(&json!({"orientation": "sideways"}), 1).is_err()); assert!(cmd::file::pdf_options(&json!({"orientation": "sideways"}), 1).is_err());
} }
/// Handout `perPage` accepts only 1, 2, 3, 4, 6 and 9; anything else is an error, not a 9-up page.
#[test]
fn pdf_options_reject_unsupported_per_page() {
for n in [1, 2, 3, 4, 6, 9] {
let o = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap();
assert!(matches!(o.layout, deckcraft_pdf::PageLayout::Handouts { per_page } if per_page as usize == n));
}
for n in [0, 5, 7, 8, 10] {
let e = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap_err();
assert!(e.to_string().contains("1, 2, 3, 4, 6, 9"), "perPage {n}: {e}");
}
// Not a count at all: refused too, instead of falling back to 6.
for v in [json!(-1), json!(5.5), json!("6")] {
assert!(cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": v}), 1).is_err(), "perPage {v}");
}
}
#[test] #[test]
fn shape_line_sets_compound_and_arrow_sizes() { fn shape_line_sets_compound_and_arrow_sizes() {
// The Format Shape pane's Line page: compound type, arrowheads and their sizes. // The Format Shape pane's Line page: compound type, arrowheads and their sizes.
@@ -3206,6 +3223,7 @@ fn numbering_takes_color_and_size() {
props.bullet_font_follow_text = Some(true); props.bullet_font_follow_text = Some(true);
props.bullet_color_follow_text = Some(true); props.bullet_color_follow_text = Some(true);
props.bullet_size_follow_text = Some(true); props.bullet_size_follow_text = Some(true);
props.bullet_size_points = Some(12.0);
} }
s.execute("format.numbering", &json!({"on": true, "scheme": "romanUcPeriod", "start": 3, "color": "accent2", "size": 150})).unwrap(); s.execute("format.numbering", &json!({"on": true, "scheme": "romanUcPeriod", "start": 3, "color": "accent2", "size": 150})).unwrap();
let para = |s: &Session| { let para = |s: &Session| {
@@ -3219,9 +3237,11 @@ fn numbering_takes_color_and_size() {
assert_eq!(pp.bullet_font_follow_text, Some(true)); assert_eq!(pp.bullet_font_follow_text, Some(true));
assert_eq!(pp.bullet_color_follow_text, None); assert_eq!(pp.bullet_color_follow_text, None);
assert_eq!(pp.bullet_size_follow_text, None); assert_eq!(pp.bullet_size_follow_text, None);
assert_eq!(pp.bullet_size_points, None);
assert_eq!(pp.bullet_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2))); assert_eq!(pp.bullet_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2)));
s.execute("format.bullets", &json!({"on": true, "char": "★", "size": 9000})).unwrap(); s.execute("format.bullets", &json!({"on": true, "char": "★", "size": 9000})).unwrap();
assert_eq!(para(&s).bullet_size, Some(4.0)); assert_eq!(para(&s).bullet_size, Some(4.0));
assert_eq!(para(&s).bullet_size_points, None);
assert_eq!(para(&s).bullet_font_follow_text, None); assert_eq!(para(&s).bullet_font_follow_text, None);
} }
@@ -4294,6 +4314,38 @@ fn a_params_doc_spells_out_each_parameter() {
); );
} }
/// `shape.fill`'s params doc names each key and kind its `gradient` takes.
#[test]
fn shape_fill_documents_each_gradient_key_and_kind() {
use deckcraft_model::style::GradientShape;
let params = cmd::parse_params(find_command("shape.fill").unwrap().params).unwrap();
let about = params.iter().find(|p| p.name == "gradient").unwrap().about;
assert_eq!(cmd::param_keys(about).unwrap(), ["stops", "kind", "angle", "focus", "rotateWithShape"]);
for kind in ["linear", "radial", "rectangular", "path"] {
assert!(about.contains(kind), "{kind}: {about}");
let g = cmd::shape::gradient_param(&json!({
"stops": [[0, "#ffffff"], [1, "#000000"]],
"kind": kind,
"angle": 30,
"focus": [0, 0, 1, 1],
"rotateWithShape": false
}))
.unwrap();
assert!(!g.rotate_with_shape, "{kind}");
match g.shape {
GradientShape::Linear { angle, .. } => assert_eq!((kind, angle), ("linear", 30.0)),
GradientShape::Path { path, focus } => {
let expected = match kind {
"radial" => "circle",
"rectangular" => "rect",
_ => "shape",
};
assert_eq!((path.as_str(), focus), (expected, [0.0, 0.0, 1.0, 1.0]), "{kind}");
}
}
}
}
#[test] #[test]
fn what_a_command_acts_on_names_only_keys_it_takes() { fn what_a_command_acts_on_names_only_keys_it_takes() {
for c in command_specs() { for c in command_specs() {
@@ -5572,6 +5624,170 @@ fn edit_points_conversion_is_one_undo_step() {
assert!(preset(&s), "the only step was the conversion"); assert!(preset(&s), "the only step was the conversion");
} }
/// #1621: build overlapping objects with an ordinary quick style on the foreground shape.
fn no_fill_overlap(s: &mut Session, text: bool) -> (ShapeId, ShapeId) {
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let under = s
.execute(
if text { "insert.textBox" } else { "shape.insert" },
&json!({"preset": "rect", "rect": [100, 300, 300, 60], "text": if text { "UNDERLYING TEXT" } else { "" }}),
)
.unwrap()["id"]
.as_u64()
.unwrap();
let over = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80, 270, 350, 140]})).unwrap()["id"].as_u64().unwrap();
(ShapeId(under as u32), ShapeId(over as u32))
}
/// No Fill passes interior clicks through while preserving outline and actual-fill targets.
#[test]
fn no_fill_shape_allows_selecting_and_editing_underlying_text() {
let mut s = session();
let (under, over) = no_fill_overlap(&mut s, true);
s.execute("shape.fill", &json!({"none": true})).unwrap();
assert!(s.doc().unwrap().shape(over).unwrap().style.is_some(), "the ordinary quick style remains");
s.execute("edit.deselect", &json!({})).unwrap();
let p = deckcraft_geom::Point::new(200.0, 310.0);
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under));
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(tev(kind, p.x, p.y)).unwrap();
}
assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
assert_eq!(text_sel(&s).unwrap().shape, under);
// The transparent foreground also leaves an already-active text editor's caret alone.
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(tev(kind, p.x + 20.0, p.y)).unwrap();
}
assert_eq!(text_sel(&s).unwrap().shape, under);
s.execute("edit.deselect", &json!({})).unwrap();
// The rectangle's outline is still a target, and selecting it does not remove its style.
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(tev(kind, 80.0, 330.0)).unwrap();
}
assert_eq!(s.doc().unwrap().selection.shapes, vec![over]);
assert!(s.doc().unwrap().shape(over).unwrap().style.is_some());
// A real fill still catches clicks, even with 100% transparency (distinct from No Fill).
for transparency in [0.0, 1.0] {
s.execute("edit.select", &json!({"ids": [over.0]})).unwrap();
s.execute("shape.fill", &json!({"color": "#336699", "transparency": transparency})).unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over));
}
}
/// Rotation and pointer tolerance must preserve both pass-through interiors and outline hits.
#[test]
fn no_fill_shape_hit_testing_respects_rotation_and_pointer_tolerance() {
let mut s = session();
let (under, over) = no_fill_overlap(&mut s, false);
s.execute("shape.fill", &json!({"none": true})).unwrap();
s.execute("shape.rotate", &json!({"deg": 30})).unwrap();
let x = s.doc().unwrap().shape(over).unwrap().xfrm.unwrap();
let p = x.affine() * deckcraft_geom::Point::new(x.w / 2.0, x.h / 2.0);
let edge = x.affine() * deckcraft_geom::Point::new(0.0, x.h / 4.0);
for tol in [0.5, 3.0, 8.0] {
s.execute("edit.deselect", &json!({})).unwrap();
assert!(matches!(s.hit_test(p, tol), Some(tools::Hit::Shape { id, .. }) if id == under));
assert!(matches!(s.hit_test(edge, tol), Some(tools::Hit::Shape { id, .. }) if id == over));
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(PointerEvent { tol, ..tev(kind, p.x, p.y) }).unwrap();
}
assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
}
}
/// Style, theme and absent fills follow rendering, with explicit fill taking precedence.
#[test]
fn no_fill_hit_testing_resolves_style_and_theme_fills() {
use std::sync::Arc;
for source in ["style", "theme", "absent"] {
let mut s = session();
let (under, over) = no_fill_overlap(&mut s, true);
{
let st = s.doc_mut().unwrap();
let doc = Arc::make_mut(&mut st.doc);
if source == "theme" {
Arc::make_mut(&mut doc.masters[0]).theme.format.fills[0] = model::Fill::None;
}
let sh = Arc::make_mut(&mut doc.slides[st.selection.slide]).shapes.iter_mut().find(|sh| sh.id == over).unwrap();
assert!(sh.fill.is_none(), "the shape has no explicit fill override");
if source == "absent" {
sh.style = None;
} else {
sh.style.as_mut().unwrap().fill_ref.0 = if source == "style" { 0 } else { 1 };
}
}
s.execute("edit.deselect", &json!({})).unwrap();
let p = deckcraft_geom::Point::new(200.0, 310.0);
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under), "{source}");
// An explicit fill takes precedence over the no-fill style/theme.
s.execute("edit.select", &json!({"ids": [over.0]})).unwrap();
s.execute("shape.fill", &json!({"color": "#336699"})).unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over), "{source}");
}
}
/// Text on a foreground No Fill shape retains its selection and editing target.
#[test]
fn no_fill_text_shapes_keep_their_interior_text_target() {
let mut s = session();
let (_, over) = no_fill_overlap(&mut s, true);
s.execute("text.set", &json!({"id": over.0, "text": "FOREGROUND TEXT"})).unwrap();
s.execute("shape.fill", &json!({"none": true})).unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
let p = deckcraft_geom::Point::new(200.0, 310.0);
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, text: true }) if id == over));
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
assert_eq!(text_sel(&s).unwrap().shape, over);
}
/// A locked No Fill shape remains selectable on its outline, but dragging cannot move it.
#[test]
fn no_fill_locked_shape_keeps_outline_selection_without_moving() {
let mut s = session();
let (under, over) = no_fill_overlap(&mut s, true);
s.execute("shape.fill", &json!({"none": true})).unwrap();
s.execute("shape.lock", &json!({"locked": true})).unwrap();
let before = s.doc().unwrap().shape(over).unwrap().xfrm;
s.execute("edit.deselect", &json!({})).unwrap();
let inside = deckcraft_geom::Point::new(200.0, 310.0);
assert!(matches!(s.hit_test(inside, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under));
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(tev(kind, inside.x, inside.y)).unwrap();
}
assert_eq!(text_sel(&s).unwrap().shape, under);
s.execute("edit.deselect", &json!({})).unwrap();
let edge = deckcraft_geom::Point::new(80.0, 330.0);
assert!(matches!(s.hit_test(edge, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over));
s.pointer(tev(PointerKind::Down, edge.x, edge.y)).unwrap();
s.pointer(tev(PointerKind::Drag, edge.x + 40.0, edge.y + 20.0)).unwrap();
s.pointer(tev(PointerKind::Up, edge.x + 40.0, edge.y + 20.0)).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.selection.shapes, vec![over]);
assert_eq!(st.shape(over).unwrap().xfrm, before);
assert!(st.shape(over).unwrap().locked);
}
/// Removing both fill and outline leaves the empty interior transparent to clicks, locked or not.
#[test]
fn no_fill_shape_without_text_or_outline_passes_interior_clicks() {
for locked in [false, true] {
let mut s = session();
let (under, _) = no_fill_overlap(&mut s, false);
s.execute("shape.fill", &json!({"none": true})).unwrap();
s.execute("shape.line", &json!({"none": true})).unwrap();
s.execute("shape.lock", &json!({"locked": locked})).unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
let p = deckcraft_geom::Point::new(200.0, 310.0);
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under));
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(tev(kind, p.x, p.y)).unwrap();
}
assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
}
}
/// #931: while the text of a placeholder is being edited, a shape in front of it still takes a /// #931: while the text of a placeholder is being edited, a shape in front of it still takes a
/// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret. /// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret.
#[test] #[test]
@@ -5645,6 +5861,204 @@ fn box_of(s: &mut Session, id: u64) -> serde_json::Value {
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone() s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
} }
/// Two selected boxes with different sizes and aligned right edges, on a blank slide.
fn resize_selection_scene() -> (Session, [u64; 2]) {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.prefs.smart_guides = false;
s.prefs.snap_to_grid = false;
let ids = [[100, 100, 100, 80], [120, 300, 80, 100]]
.map(|rect| s.execute("shape.insert", &json!({"preset": "rect", "rect": rect, "text": "Resize test"})).unwrap()["id"].as_u64().unwrap());
s.execute("edit.select", &json!({"ids": ids})).unwrap();
(s, ids)
}
/// Simulate file-loaded shapes whose stored boxes have not yet been fitted to their text.
fn pending_resize_autofit(s: &mut Session, ids: &[u64]) {
s.edit(|doc, sel| {
let shapes = crate::shapes_mut(doc, sel).unwrap();
for id in ids {
let sh = deckcraft_model::find_shape_mut(shapes, ShapeId(*id as u32)).unwrap();
sh.text.as_mut().unwrap().body.autofit = Some(deckcraft_model::text::AutoFit::Shape);
}
Ok(())
})
.unwrap();
}
/// Resizing multiple selected shapes scales them proportionally around their anchors in one undo step.
#[test]
fn resizing_selected_shapes_is_one_undo_step() {
let (mut s, [a, b]) = resize_selection_scene();
let before = s.doc().unwrap().doc.clone();
let steps = s.doc().unwrap().history.undo.len();
let down = drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0));
assert_eq!(down["hit"]["kind"], "resize");
assert_eq!(down["hit"]["id"], a);
for (id, expected) in [(a, [100.0, 100.0, 150.0, 110.0]), (b, [120.0, 300.0, 120.0, 137.5])] {
let actual = box_of(&mut s, id);
for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) {
assert_eq!(actual[key], value, "{id}: {key}");
}
}
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
let after = s.doc().unwrap().doc.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before, "one undo restores both shapes");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after, "one redo resizes both shapes again");
}
/// Resizing selected shapes refits their text boxes inside the same undo transaction.
#[test]
fn resizing_selected_shapes_refits_text_in_the_same_undo_step() {
let (mut s, ids) = resize_selection_scene();
pending_resize_autofit(&mut s, &ids);
let before = s.doc().unwrap().doc.clone();
let steps = s.doc().unwrap().history.undo.len();
// An east-handle drag changes width only; a height change must come from text fitting.
drag_gesture(&mut s, (200.0, 140.0), (250.0, 140.0));
for (id, old_height) in ids.into_iter().zip([80.0, 100.0]) {
assert_ne!(box_of(&mut s, id)["h"], old_height, "both shapes' text is refitted");
}
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
let after = s.doc().unwrap().doc.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after, "redo includes the final text fit");
}
/// Clicking a resize handle without dragging neither modifies shapes nor triggers text autofit.
#[test]
fn clicking_a_resize_handle_does_not_change_or_refit_selected_shapes() {
let (mut s, ids) = resize_selection_scene();
pending_resize_autofit(&mut s, &ids);
let before = s.doc().unwrap().doc.clone();
let steps = s.doc().unwrap().history.undo.len();
let revision = s.doc().unwrap().revision;
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(PointerEvent { kind, x: 200.0, y: 180.0, tol: 3.0, ..Default::default() }).unwrap();
}
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "a click creates no undo entry");
assert_eq!(s.doc().unwrap().revision, revision);
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "a click does not dirty the document");
// Ensure this fixture would catch an accidental refit on release.
for id in ids {
cmd::text::fit_text_box(&mut s, ShapeId(id as u32)).unwrap();
}
assert_ne!(s.doc().unwrap().doc, before);
}
/// Locked shapes in a multi-selection are excluded from both resizing and post-resize text fitting.
#[test]
fn resizing_selected_shapes_skips_locked_shapes_and_their_text_fit() {
let (mut s, [a, b]) = resize_selection_scene();
pending_resize_autofit(&mut s, &[b]);
s.execute("shape.lock", &json!({"ids": [b], "locked": true})).unwrap();
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
let locked = s.doc().unwrap().shape(ShapeId(b as u32)).unwrap().clone();
let before = s.doc().unwrap().doc.clone();
let steps = s.doc().unwrap().history.undo.len();
drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0));
assert_eq!(box_of(&mut s, a)["w"], 150.0);
assert_eq!(box_of(&mut s, a)["h"], 110.0);
assert_eq!(s.doc().unwrap().shape(ShapeId(b as u32)).unwrap(), &locked, "the locked shape is neither resized nor refitted");
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
}
/// Resizing selected shapes excludes other selected resize targets from smart-guide snapping.
#[test]
fn resizing_selected_shapes_does_not_snap_to_other_resize_targets() {
for reverse in [false, true] {
let (mut s, [a, b]) = resize_selection_scene();
s.prefs.smart_guides = true;
let ids = if reverse { [b, a] } else { [a, b] };
s.execute("edit.select", &json!({"ids": ids})).unwrap();
// Within snap tolerance of the other selected shape's original right edge.
drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0));
assert_eq!(box_of(&mut s, a)["w"], 103.0, "first shape must not snap back");
assert_eq!(box_of(&mut s, b)["w"], 82.4, "selection order must not affect snapping");
}
}
/// Resizing selected shapes snaps the active shape against unselected stationary shapes and scales others proportionally.
#[test]
fn resizing_selected_shapes_still_snaps_to_stationary_shapes() {
let (mut s, [a, b]) = resize_selection_scene();
let fixed = s.execute("shape.insert", &json!({"preset": "rect", "rect": [207, 430, 100, 80]})).unwrap()["id"].as_u64().unwrap();
let fixed_before = box_of(&mut s, fixed);
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
s.prefs.smart_guides = true;
drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0));
assert_eq!(box_of(&mut s, a)["w"], 107.0);
assert!((box_of(&mut s, b)["w"].as_f64().unwrap() - 85.6).abs() < 1e-9);
assert_eq!(box_of(&mut s, fixed), fixed_before);
}
/// Secondary pictures resize uniformly around their anchor, preserving independent flips
/// and keeping the complete multi-selection resize in one undo step.
#[test]
fn resizing_selected_shapes_resizes_secondary_pictures() {
// Pointer destination, Alt, expected rectangle, expected picture, expected flips.
let cases = [
((180.0, 200.0), false, [100.0, 100.0, 80.0, 100.0], [300.0, 300.0, 80.0, 80.0], [false, false]),
((200.0, 180.0), false, [100.0, 100.0, 100.0, 80.0], [300.0, 300.0, 80.0, 80.0], [false, false]),
((220.0, 200.0), false, [100.0, 100.0, 120.0, 100.0], [300.0, 300.0, 120.0, 120.0], [false, false]),
((200.0, 220.0), false, [100.0, 100.0, 100.0, 120.0], [300.0, 300.0, 120.0, 120.0], [false, false]),
((180.0, 200.0), true, [120.0, 100.0, 60.0, 100.0], [320.0, 320.0, 60.0, 60.0], [false, false]),
((200.0, 180.0), true, [100.0, 120.0, 100.0, 60.0], [320.0, 320.0, 60.0, 60.0], [false, false]),
((80.0, 200.0), false, [80.0, 100.0, 20.0, 100.0], [280.0, 300.0, 20.0, 20.0], [true, false]),
((200.0, 80.0), false, [100.0, 80.0, 100.0, 20.0], [300.0, 280.0, 20.0, 20.0], [false, true]),
];
for (to, alt, expected_rect, expected_picture, flips) in cases {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.prefs.smart_guides = false;
s.prefs.snap_to_grid = false;
let rect = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap();
let picture = ramp_picture(&mut s, 1, [300, 300, 100, 100]).as_u64().unwrap();
s.execute("edit.select", &json!({"ids": [rect, picture]})).unwrap();
let before = s.doc().unwrap().doc.clone();
let steps = s.doc().unwrap().history.undo.len();
let mods = Mods { alt, ..Default::default() };
let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods, tol: 3.0, pressure: None };
let down = s.pointer(ev(PointerKind::Down, (200.0, 200.0))).unwrap();
assert_eq!(down["hit"]["kind"], "resize");
assert_eq!(down["hit"]["id"], rect);
assert_eq!(down["hit"]["handle"], 4);
s.pointer(ev(PointerKind::Drag, ((200.0 + to.0) / 2.0, (200.0 + to.1) / 2.0))).unwrap();
s.pointer(ev(PointerKind::Drag, to)).unwrap();
s.pointer(ev(PointerKind::Up, to)).unwrap();
for (id, expected) in [(rect, expected_rect), (picture, expected_picture)] {
let actual = box_of(&mut s, id);
for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) {
let got = actual[key].as_f64().unwrap();
assert!((got - value).abs() < 1e-9, "{to:?}, alt={alt}, shape={id}, {key}: expected {value}, got {got}");
}
let xfrm = s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().xfrm.unwrap();
assert_eq!([xfrm.flip_h, xfrm.flip_v], flips, "{to:?}, alt={alt}, shape={id}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
let after = s.doc().unwrap().doc.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after);
}
}
/// #961: dragging the east handle snaps the dragged (right) edge, even when the fixed left edge /// #961: dragging the east handle snaps the dragged (right) edge, even when the fixed left edge
/// already lines up with another shape. The nearest line used to be the fixed edge's, at distance 0. /// already lines up with another shape. The nearest line used to be the fixed edge's, at distance 0.
#[test] #[test]
@@ -6064,3 +6478,30 @@ fn master_body_placeholder_format_keeps_per_level_formatting() {
assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0))); assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0)));
assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]); assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]);
} }
#[test]
fn slide_master_view_rejects_invalid_indices() {
let mut s = Session::with_new();
for p in [json!({"master": -1}), json!({"master": 0.5}), json!({"master": 0, "layout": -1}), json!({"master": 0, "layout": 1.5})] {
let r = s.execute("view.slideMaster", &p);
assert!(r.is_err(), "invalid index accepted for {p}: {r:?}");
}
assert!(s.execute("view.slideMaster", &json!({})).is_ok());
assert!(s.execute("view.slideMaster", &json!({"master": 0})).is_ok());
}
#[test]
fn save_to_image_path_is_an_error_not_zip_bytes() {
let dir = std::env::temp_dir().join(format!("deckcraft-save-img-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (cmd, name) in [("file.save", "a.png"), ("file.saveAs", "a.jpg"), ("file.save", "a.jpeg")] {
let mut s = Session::new();
sample::open_sample(&mut s).unwrap();
let path = dir.join(name).to_string_lossy().to_string();
let before = s.doc().unwrap().path.clone();
assert!(s.execute(cmd, &json!({"path": path})).is_err(), "{cmd} {name} should fail");
assert!(!std::path::Path::new(&path).exists(), "{name} must not be written");
assert_eq!(s.doc().unwrap().path, before);
}
let _ = std::fs::remove_dir_all(&dir);
}
+3 -1
View File
@@ -150,8 +150,10 @@ enum Drag {
}, },
Resize { Resize {
id: ShapeId, id: ShapeId,
handle: u8,
orig: Xfrm, orig: Xfrm,
ids: Vec<ShapeId>,
handle: u8,
origs: Vec<(ShapeId, Xfrm)>,
start: Point, start: Point,
}, },
LineEnd { LineEnd {
+43 -4
View File
@@ -13,6 +13,7 @@ struct MoveDrag {
} }
impl Session { 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> { pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
match drag { match drag {
@@ -26,10 +27,48 @@ impl Session {
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol); return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol);
} }
Drag::Resize { id, handle, orig, start } => { Drag::Resize { id, orig, ids, handle, origs, start } => {
let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt); let keep_aspect = mods.shift || self.is_picture(id);
let nx = self.snap_resize(nx, &orig, handle, id, tol); let nx = resize_box(&orig, handle, p - start, keep_aspect, mods.alt);
self.set_xfrm(id, nx)?; 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 } => { Drag::LineEnd { id, end, orig } => {
let a = orig.affine(); let a = orig.affine();
+2 -2
View File
@@ -228,7 +228,7 @@ impl Session {
} }
/// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides. /// 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 { if x.rot != 0.0 {
self.tool.guides.clear(); self.tool.guides.clear();
return x; return x;
@@ -241,7 +241,7 @@ impl Session {
let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6)); 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_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 (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; let mut nx = x;
if moves_left { if moves_left {
nx.x += off.x; 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> { 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 x = xfrm_of(&st.doc, &st.selection, sh);
let inv = x.affine().inverse(); 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; 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); 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. // Match rendering: an explicit No Fill overrides the style, and style/theme fills
let filled = !matches!(sh.fill, Some(deckcraft_model::Fill::None)) || sh.style.is_some(); // 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 geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
let in_geo = { let in_geo = {
use deckcraft_geom::KurboShape; use deckcraft_geom::KurboShape;
+22 -4
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> { fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { 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. // Next click of a freeform: close on the start point, else add a vertex.
@@ -303,8 +304,12 @@ impl Session {
Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?,
Some(Hit::Resize { id, handle }) => { Some(Hit::Resize { id, handle }) => {
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); 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.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 }) => { Some(Hit::LineEnd { id, end }) => {
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
@@ -436,6 +441,7 @@ impl Session {
Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()})) 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> { fn up(&mut self, p: Point, mods: Mods) -> Result<Value> {
let drag = self.tool.drag.take(); let drag = self.tool.drag.take();
self.tool.guides.clear(); self.tool.guides.clear();
@@ -558,10 +564,22 @@ impl Session {
out = self.add_ink(points)?; 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(); self.commit();
let _ = cmd::text::fit_text_box(self, id);
self.refit_after_resize(id)?;
} }
Some(Drag::TextSelect { before, .. }) => { Some(Drag::TextSelect { before, .. }) => {
out = self.paint_if_selection_changed(before, out)?; out = self.paint_if_selection_changed(before, out)?;
+75 -3
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 /// 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. /// where one exists), tried in order after the family itself.
pub fn substitutes(family: &str) -> &'static [&'static str] { 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"], "calibri" | "calibri light" => &["Carlito", "Liberation Sans", "Arial", "Inter"],
"cambria" | "cambria math" => &["Caladea", "Liberation Serif", "Times New Roman", "Merriweather"], "cambria" | "cambria math" => &["Caladea", "Liberation Serif", "Times New Roman", "Merriweather"],
"arial" | "helvetica" | "helvetica neue" | "arial nova" => &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"], "arial" | "helvetica" | "helvetica neue" | "arial nova" => &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"],
"times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times", "Merriweather"], "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" => { "aptos" | "aptos display" | "aptos narrow" | "segoe ui" | "segoe ui light" | "segoe ui semibold" | "tahoma" | "verdana" => {
&["Inter", "Helvetica Neue", "Arial", "Liberation Sans"] &["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 /// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
/// them breaks into the same lines. /// them breaks into the same lines.
fn has_metric_twin(family: &str) -> bool { fn has_metric_twin(family: &str) -> bool {
matches!( matches!(
family.to_ascii_lowercase().as_str(), 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"
) )
} }
@@ -1080,6 +1148,7 @@ impl FontDb {
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri, /// 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, /// 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). /// 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. /// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> { 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) { if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
@@ -1088,6 +1157,9 @@ impl FontDb {
if let Some(twin) = office_twin(family) { if let Some(twin) = office_twin(family) {
return self.face(twin, style); return self.face(twin, style);
} }
if is_monospace_family(family) {
return self.face(family, style);
}
self.face(MISSING_FONT_FAMILY, style) self.face(MISSING_FONT_FAMILY, style)
} }
+2 -1
View File
@@ -15,7 +15,8 @@ mod shape;
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets}; pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
pub use fontdb::{ 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; pub use harfrust::Feature;
use harfrust::Tag; use harfrust::Tag;
+161 -38
View File
@@ -37,56 +37,146 @@ pub struct ShapeParams<'a> {
pub language: Option<&'a str>, pub language: Option<&'a str>,
} }
/// Most bytes of text given to the shaper at once. The shaper lets one call grow its buffers to
/// 256 glyphs per character (a font's substitutions decide how many), so a long item is shaped in
/// pieces of this size, each with the rest of the paragraph as context.
const MAX_SHAPE_CHUNK: usize = 1024;
/// Most glyphs kept per character of a piece: a piece the font expands further is laid out one
/// glyph per character from the cmap instead.
const MAX_GLYPHS_PER_CHAR: usize = 32;
/// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order /// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All /// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing. /// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> { pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
let (Some(pre), Some(text), Some(post)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else { let (Some(_), Some(text), Some(_)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
return Vec::new(); return Vec::new();
}; };
let mut out = Vec::with_capacity(text.len()); let chunks = chunks(text, MAX_SHAPE_CHUNK);
let mut pieces: Vec<Vec<ShapedGlyph>> = Vec::with_capacity(chunks.len());
let shaped = face.hb().map(|hb| { let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new(); // Pieces of one item share the script the whole item would be guessed to have.
buf.set_pre_context(pre); let item_script = (chunks.len() > 1 && params.script.is_none()).then(|| {
buf.set_post_context(post); let mut probe = UnicodeBuffer::new();
for (i, c) in text.char_indices() { for c in text.chars() {
buf.add(map(c), (range.start + i) as u32); probe.add(map(c), 0);
} }
buf.guess_segment_properties(); probe.guess_segment_properties();
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) { probe.script()
buf.set_script(s); });
} for c in &chunks {
if let Some(l) = params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok()) let r = (range.start + c.start)..(range.start + c.end);
{ let (Some(pre), Some(piece), Some(post)) = (para.get(..r.start), para.get(r.clone()), para.get(r.end..)) else { continue };
buf.set_language(l); let mut buf = UnicodeBuffer::new();
} buf.set_pre_context(pre);
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight }); buf.set_post_context(post);
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features)); for (i, c) in piece.char_indices() {
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { buf.add(map(c), (r.start + i) as u32);
out.push(ShapedGlyph { }
gid: info.glyph_id, buf.guess_segment_properties();
cluster: info.cluster as usize, if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
x_advance: pos.x_advance, buf.set_script(s);
x_offset: pos.x_offset, } else if let Some(s) = item_script {
y_offset: pos.y_offset, buf.set_script(s);
}); }
if let Some(l) =
params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
{
buf.set_language(l);
}
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
let budget = piece.chars().count().saturating_mul(MAX_GLYPHS_PER_CHAR);
if gb.len() > budget {
// Font-controlled expansion past anything real text needs: keep it bounded.
pieces.push(cmap_glyphs(face, para, r, params.rtl, &map));
continue;
}
pieces.push(
gb.glyph_infos()
.iter()
.zip(gb.glyph_positions())
.map(|(info, pos)| ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
})
.collect(),
);
} }
}); });
if shaped.is_none() if shaped.is_none() {
&& let Some(f) = face.skrifa()
{
// No shaper for this face: one glyph per character from the cmap, in visual order. // No shaper for this face: one glyph per character from the cmap, in visual order.
let cmap = f.charmap(); return cmap_glyphs(face, para, range, params.rtl, &map);
let gm = f.glyph_metrics(Size::unscaled(), face.location()); }
for (i, c) in text.char_indices() { // Each piece is in visual order; right to left, the later pieces come first.
let g = cmap.map(map(c)).unwrap_or_default(); if params.rtl {
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5); pieces.reverse();
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 }); }
} let mut out = Vec::with_capacity(pieces.iter().map(Vec::len).sum());
if params.rtl { for p in pieces {
out.reverse(); out.extend(p);
}
out
}
/// One glyph per character of `para[range]` from the cmap, in visual order.
fn cmap_glyphs(face: &FontFace, para: &str, range: Range<usize>, rtl: bool, map: &impl Fn(char) -> char) -> Vec<ShapedGlyph> {
let (Some(text), Some(f)) = (para.get(range.clone()), face.skrifa()) else { return Vec::new() };
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), face.location());
let mut out = Vec::with_capacity(text.len());
for (i, c) in text.char_indices() {
let g = cmap.map(map(c)).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
}
if rtl {
out.reverse();
}
out
}
/// `text` cut into byte ranges of at most `max` bytes (or one character, if longer), at the last
/// space of a piece where there is one, else before a character that starts a cluster (not a
/// combining mark or joiner, not after a joiner). Text that fits is one piece.
fn chunks(text: &str, max: usize) -> Vec<Range<usize>> {
use unicode_bidi::BidiClass::{BN, NSM};
let mut out = Vec::new();
let mut start = 0;
while text.len() - start > max {
let window = text.get(start..).unwrap_or_default();
let mut space = None;
let mut boundary = None;
let mut any = None;
let mut prev: Option<char> = None;
for (i, c) in window.char_indices() {
if i > max {
break;
}
if i > 0 {
any = Some(i);
let joined = prev.is_some_and(|p| matches!(unicode_bidi::bidi_class(p), BN));
if !joined && !matches!(unicode_bidi::bidi_class(c), NSM | BN) {
boundary = Some(i);
if prev.is_some_and(char::is_whitespace) {
space = Some(i);
}
}
}
prev = Some(c);
} }
// A piece always moves forward by at least one character.
let first = window.chars().next().map_or(window.len(), char::len_utf8);
let cut = space.filter(|&i| i >= max / 2).or(boundary).or(any).unwrap_or(first).max(first);
out.push(start..start + cut);
start += cut;
}
if start < text.len() || out.is_empty() {
out.push(start..text.len());
} }
out out
} }
@@ -121,4 +211,37 @@ mod tests {
// Not a char boundary. // Not a char boundary.
assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty()); assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty());
} }
#[test]
fn long_items_are_shaped_in_bounded_pieces() {
for text in ["word ".repeat(2_000), "e\u{301}".repeat(2_000), "x".repeat(5_000), "\u{1f469}\u{200d}\u{1f4bb} ".repeat(500)] {
let pieces = chunks(&text, MAX_SHAPE_CHUNK);
assert_eq!(pieces.first().map(|r| r.start), Some(0));
assert_eq!(pieces.last().map(|r| r.end), Some(text.len()));
for w in pieces.windows(2) {
assert_eq!(w[0].end, w[1].start);
}
for r in &pieces {
assert!(!r.is_empty() && r.len() <= MAX_SHAPE_CHUNK);
// Never before a combining mark, never after a joiner.
let rest = &text[r.end..];
assert!(!rest.starts_with('\u{301}'));
assert!(!text[..r.end].ends_with('\u{200d}') || rest.is_empty());
}
}
assert_eq!(chunks("short", MAX_SHAPE_CHUNK), vec![0..5]);
assert_eq!(chunks("", MAX_SHAPE_CHUNK), vec![0..0]);
}
#[test]
fn long_items_keep_every_cluster_in_visual_order() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let text = "ab cd ".repeat(1_000);
let g = shape_range(&face, &text, 0..text.len(), &ShapeParams::default(), |c| c);
assert_eq!(g.len(), text.len());
assert!(g.windows(2).all(|w| w[0].cluster < w[1].cluster));
let rtl = shape_range(&face, &text, 0..text.len(), &ShapeParams { rtl: true, ..Default::default() }, |c| c);
assert_eq!(rtl.len(), text.len());
assert!(rtl.windows(2).all(|w| w[0].cluster > w[1].cluster));
}
} }
+74 -3
View File
@@ -216,9 +216,13 @@ fn missing_document_fonts_are_drawn_in_calibri() {
assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}"); assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}");
} }
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing. // PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
for (missing, twin) in for (missing, twin) in [
[("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")] ("Liberation Sans", "Arial"),
{ ("Liberation Serif", "Times New Roman"),
("Arial CE", "Arial"),
("Times New Roman CYR", "Times New Roman"),
("Courier New CE", "Courier New"),
] {
if !db.has_family(missing) { if !db.has_family(missing) {
assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}"); assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}");
} }
@@ -237,6 +241,73 @@ fn office_fonts_have_substitutes() {
assert!(substitutes("Times New Roman").contains(&"Liberation Serif")); assert!(substitutes("Times New Roman").contains(&"Liberation Serif"));
assert!(substitutes("Aptos").contains(&"Inter")); assert!(substitutes("Aptos").contains(&"Inter"));
assert!(substitutes("zzz").is_empty()); assert!(substitutes("zzz").is_empty());
for mono in [
"Courier New",
"Cousine",
"Consolas",
"Menlo",
"Monaco",
"Source Code Pro",
"JetBrains Mono",
"Cascadia Code",
"Cascadia Code PL",
"Cascadia Mono",
"Fira Code",
"Inconsolata",
"DejaVu Sans Mono",
"Roboto Mono",
"Hack",
"Ubuntu Mono",
"Custom Monospace",
"Fantasque Sans Mono",
] {
assert!(substitutes(mono).contains(&"Liberation Mono"), "{mono} should have Liberation Mono substitute");
assert!(is_monospace_family(mono), "{mono} should be recognized as monospace");
}
for prop in ["Calibri", "Arial", "Times New Roman", "Inter", "Monotype Corsiva", "Monoton", "Monotype Baskerville", "Code Pro", "zzz"] {
assert!(!is_monospace_family(prop), "{prop} should not be recognized as monospace");
}
// Explicit checks requested in review: Consolas, Courier New, Fira Code, Monotype Corsiva, Monoton,
// and ensuring proportional families maintain their unchanged substitutes.
assert!(is_monospace_family("Consolas"));
assert!(is_monospace_family("Courier New"));
assert!(is_monospace_family("Fira Code"));
assert!(!is_monospace_family("Monotype Corsiva"));
assert!(!is_monospace_family("Monoton"));
assert_eq!(substitutes("Calibri"), &["Carlito", "Liberation Sans", "Arial", "Inter"]);
assert_eq!(substitutes("Arial"), &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"]);
assert_eq!(substitutes("Times New Roman"), &["Liberation Serif", "Times New Roman", "Times", "Merriweather"]);
assert_eq!(substitutes("Georgia"), &["Merriweather", "Liberation Serif", "Georgia"]);
assert!(substitutes("Monotype Corsiva").is_empty());
assert!(substitutes("Monoton").is_empty());
}
#[test]
fn missing_monospace_fonts_keep_monospace_fallback() {
let db = FontDb::global();
let mono_sub = db.face("Unavailable JetBrains Mono", "Regular").family.clone();
let calibri_target = db.face(MISSING_FONT_FAMILY, "Regular").family.clone();
assert!(is_monospace_family(&mono_sub), "substitute {mono_sub} must be monospace");
// Cousine is metric-compatible with Courier New / Liberation Mono and must not fall back to Calibri.
if !db.has_family("Cousine") {
assert_eq!(db.document_face("Cousine", "Regular").family, mono_sub);
}
// Monospace fonts missing from the system must resolve to a monospace substitute.
for mono in ["Unavailable JetBrains Mono", "Custom App Code", "Unknown Space Mono", "Mock Terminal Font"] {
let f = db.document_face(mono, "Regular");
assert_eq!(f.family, mono_sub, "{mono}");
assert!(is_monospace_family(&f.family), "{mono} must resolve to a monospace family");
if mono_sub != calibri_target {
assert_ne!(f.family, calibri_target, "{mono} must not fall back to Calibri");
}
}
} }
#[test] #[test]
+22 -3
View File
@@ -71,7 +71,10 @@ impl Unit {
/// `2.08"` style text for a length in points. /// `2.08"` style text for a length in points.
pub fn format(self, pt: f64) -> String { pub fn format(self, pt: f64) -> String {
let v = pt / self.points_per_unit(); let v = pt / self.points_per_unit();
let s = format!("{v:.2}"); let mut s = format!("{v:.2}");
if s.starts_with('-') && s[1..].bytes().all(|b| b == b'0' || b == b'.') {
s.remove(0);
}
format!("{s}{}", self.suffix()) format!("{s}{}", self.suffix())
} }
/// Parses `2.5`, `2.5"`, `2.5 in`, `6 cm`, `30 pt`, `40px`, `5mm` into points. A bare number uses `self`. /// Parses `2.5`, `2.5"`, `2.5 in`, `6 cm`, `30 pt`, `40px`, `5mm` into points. A bare number uses `self`.
@@ -153,8 +156,8 @@ impl Xfrm {
} }
/// Rotation normalised to 0..360. /// Rotation normalised to 0..360.
pub fn rot_norm(&self) -> f64 { pub fn rot_norm(&self) -> f64 {
let r = self.rot % 360.0; let r = self.rot.rem_euclid(360.0);
if r < 0.0 { r + 360.0 } else { r } if r >= 360.0 { 0.0 } else { r }
} }
} }
@@ -222,6 +225,14 @@ pub fn preset_flatten(path: &BezPath, f: &mut dyn FnMut(PathEl)) {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn format_has_no_negative_zero() {
assert_eq!(Unit::Points.format(-0.001), "0.00 pt");
assert_eq!(Unit::Inches.format(-0.001), "0.00\"");
assert!(!Unit::Centimeters.format(-0.01).starts_with('-'));
assert_eq!(Unit::Points.format(-1.5), "-1.50 pt");
}
#[test] #[test]
fn emu_roundtrip_is_exact() { fn emu_roundtrip_is_exact() {
for emu in [0i64, 1, 12_699, 12_700, 914_400, 12_192_000, 6_858_000, -5, 123_456_789] { for emu in [0i64, 1, 12_699, 12_700, 914_400, 12_192_000, 6_858_000, -5, 123_456_789] {
@@ -266,6 +277,14 @@ mod tests {
assert!((p.x - 300.0).abs() < 1e-9 && (p.y - 200.0).abs() < 1e-9); assert!((p.x - 300.0).abs() < 1e-9 && (p.y - 200.0).abs() < 1e-9);
} }
#[test]
fn rot_norm_stays_below_360() {
for rot in [-1e-15, -1e-13, -f64::EPSILON, -0.0, 360.0, 720.0, -360.0] {
let n = Xfrm { rot, ..Default::default() }.rot_norm();
assert!((0.0..360.0).contains(&n), "rot {rot} -> {n}");
}
}
#[test] #[test]
fn segment_distance() { fn segment_distance() {
assert!((dist_to_segment(Point::new(5.0, 5.0), Point::new(0.0, 0.0), Point::new(10.0, 0.0)) - 5.0).abs() < 1e-9); assert!((dist_to_segment(Point::new(5.0, 5.0), Point::new(0.0, 0.0), Point::new(10.0, 0.0)) - 5.0).abs() < 1e-9);
+44 -7
View File
@@ -566,6 +566,19 @@ impl<'a> SliceDecoder<'a> {
(refs, mvs, false) (refs, mvs, false)
} }
/// Temporal direct mvs (8.4.1.2.3) from the co-located mv and `DistScaleFactor` (clamped
/// to ±1024 in `SliceDecoder`): mvL0 = clip_mv((mvCol · dsf + 128) >> 8), mvL1 =
/// clip_mv(mvL0 − mvCol), both clipped to the spec's motion-vector range −2^15..2^15−1.
/// The i32 maths cannot overflow; a truncating `as i16` used to wrap mvL0 past the range,
/// after which the mvL1 subtraction overflowed a debug build. mvL1 is scaled from the
/// unclamped mvL0 with only the results clipped, as the sibling SoundCraft decoder's
/// review established (soundcraft#994): dsf 512, mvCol 32767 keeps mvL1 at 32767.
fn temporal_direct_mvs(dsf: i32, mv_col: [i16; 2]) -> ([i16; 2], [i16; 2]) {
let clamp = |v: i32| v.clamp(-(1 << 15), (1 << 15) - 1) as i16;
let m0 = mv_col.map(|c| (dsf * i32::from(c) + 128) >> 8);
(m0.map(clamp), [clamp(m0[0] - i32::from(mv_col[0])), clamp(m0[1] - i32::from(mv_col[1]))])
}
/// Derive direct-mode motion for the 8x8 blocks in `b8_mask` of the current MB. /// Derive direct-mode motion for the 8x8 blocks in `b8_mask` of the current MB.
fn derive_direct(&mut self, b8_mask: u8) -> Result<()> { fn derive_direct(&mut self, b8_mask: u8) -> Result<()> {
let spatial = self.sh.direct_spatial_mv_pred; let spatial = self.sh.direct_spatial_mv_pred;
@@ -600,13 +613,7 @@ impl<'a> SliceDecoder<'a> {
} }
}; };
let (dsf, ltr) = self.tdirect.get(ref0 as usize).copied().unwrap_or((256, true)); let (dsf, ltr) = self.tdirect.get(ref0 as usize).copied().unwrap_or((256, true));
let (mv0, mv1) = if ltr { let (mv0, mv1) = if ltr { (mv_col, [0, 0]) } else { Self::temporal_direct_mvs(dsf, mv_col) };
(mv_col, [0, 0])
} else {
let s = |c: i16| ((dsf * c as i32 + 128) >> 8) as i16;
let m0 = [s(mv_col[0]), s(mv_col[1])];
(m0, [m0[0] - mv_col[0], m0[1] - mv_col[1]])
};
([ref0, 0], [mv0, mv1]) ([ref0, 0], [mv0, mv1])
}; };
self.set_motion(x, y, unit, unit, refs, mvs); self.set_motion(x, y, unit, unit, refs, mvs);
@@ -1481,3 +1488,33 @@ impl<'a> SliceDecoder<'a> {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::SliceDecoder;
/// Temporal direct mvs stay inside the spec's motion-vector range −2^15..2^15−1 with a
/// large `DistScaleFactor` and a co-located mv at an i16 extreme: the scale used to
/// truncate past the range with `as i16`, and mvL0 − mvCol then overflowed a debug build
/// ("attempt to subtract with overflow"). `dsf` 256 is the identity scale.
#[test]
fn temporal_direct_mvs_at_i16_extremes_stay_in_the_spec_range() {
for dsf in [-1024i32, -1, 256, 1023] {
for col in [[i16::MIN, i16::MIN], [i16::MAX, i16::MAX], [i16::MIN, i16::MAX]] {
let (mv0, mv1) = SliceDecoder::temporal_direct_mvs(dsf, col);
for mv in [mv0, mv1] {
for c in mv {
assert!((-(1 << 15)..(1 << 15)).contains(&i32::from(c)), "dsf {dsf}, col {col:?}: {mv:?}");
}
}
if dsf == 256 {
assert_eq!((mv0, mv1), (col, [0, 0]), "dsf 256 is the identity scale");
}
}
}
// soundcraft#994: mvL1 is scaled from the unclamped mvL0 with only the results clipped;
// deriving it from the clipped mvL0 gave [0, 0] here.
let (mv0, mv1) = SliceDecoder::temporal_direct_mvs(512, [i16::MAX, 0]);
assert_eq!((mv0, mv1), ([i16::MAX, 0], [i16::MAX, 0]), "mvL1 keeps mvL0's off-range part");
}
}
+23 -1
View File
@@ -469,6 +469,9 @@ pub fn change_case(s: &str, mode: &str) -> String {
/// piece is the changed form of the matching input piece, even where a character changes length. /// piece is the changed form of the matching input piece, even where a character changes length.
pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> { pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
let mut start = true; let mut start = true;
// All characters of all pieces, so word-final sigma sees neighbours across piece boundaries.
let all: Vec<char> = parts.iter().flat_map(|s| s.chars()).collect();
let mut base = 0;
parts parts
.iter() .iter()
.map(|s| match mode { .map(|s| match mode {
@@ -479,10 +482,14 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
// `title` restarts after whitespace, sentence case after sentence punctuation. // `title` restarts after whitespace, sentence case after sentence punctuation.
let title = mode == "title"; let title = mode == "title";
let mut out = String::new(); let mut out = String::new();
for c in s.chars() { for (j, c) in s.chars().enumerate() {
let i = base + j;
if start && c.is_alphabetic() { if start && c.is_alphabetic() {
out.extend(c.to_uppercase()); out.extend(c.to_uppercase());
start = false; start = false;
} else if c == 'Σ' && i > 0 && all[i - 1].is_alphabetic() && !all.get(i + 1).is_some_and(|n| n.is_alphabetic()) {
// Word-final capital sigma lowercases to ς, as `str::to_lowercase` does.
out.push('ς');
} else { } else {
out.extend(c.to_lowercase()); out.extend(c.to_lowercase());
} }
@@ -490,6 +497,7 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
start = true; start = true;
} }
} }
base += s.chars().count();
out out
} }
}) })
@@ -504,6 +512,20 @@ mod tests {
TextBody::from_text(t) TextBody::from_text(t)
} }
#[test]
fn title_and_sentence_final_sigma() {
assert_eq!(change_case("ΟΔΟΣ", "title"), "Οδος");
assert_eq!(change_case("ΟΔΟΣ ΜΟΥ.", "sentence"), "Οδος μου.");
assert_eq!(change_case("ΟΔΟΣ", "lower"), "οδος");
}
#[test]
fn title_sigma_across_runs_is_not_final() {
assert_eq!(change_case_parts(&["ΟΔΟΣ", "ΑΣ"], "title"), ["Οδοσ", "ας"]);
assert_eq!(change_case_parts(&["ΟΔΟ", "ΣΑ"], "title"), ["Οδο", "σα"]);
assert_eq!(change_case_parts(&["ΟΔΟΣ", " ΜΟΥ"], "title"), ["Οδος", " Μου"]);
}
#[test] #[test]
fn insert_and_split() { fn insert_and_split() {
let mut b = body("Hello"); let mut b = body("Hello");
+3 -1
View File
@@ -55,10 +55,12 @@ impl ColorRef {
}; };
deckcraft_color::apply(base, &self.mods) deckcraft_color::apply(base, &self.mods)
} }
/// Alpha (0–1) from an `alpha` transform, if any. /// Alpha (0–1) from the `alpha`, `alphaMod` and `alphaOff` transforms, applied in order.
pub fn alpha(&self) -> f64 { pub fn alpha(&self) -> f64 {
self.mods.iter().fold(1.0, |a, m| match m { self.mods.iter().fold(1.0, |a, m| match m {
ColorTransform::Alpha(v) => (*v as f64 / 100_000.0).clamp(0.0, 1.0), ColorTransform::Alpha(v) => (*v as f64 / 100_000.0).clamp(0.0, 1.0),
ColorTransform::AlphaMod(v) => (a * *v as f64 / 100_000.0).clamp(0.0, 1.0),
ColorTransform::AlphaOff(v) => (a + *v as f64 / 100_000.0).clamp(0.0, 1.0),
_ => a, _ => a,
}) })
} }
+12
View File
@@ -327,3 +327,15 @@ fn only_web_and_mail_links_are_safe_to_open() {
assert!(!safe_url(url), "{url}"); assert!(!safe_url(url), "{url}");
} }
} }
#[test]
fn colour_alpha_follows_alpha_mod_and_off() {
use deckcraft_color::{ColorTransform, Rgba};
let base = || ColorRef::rgb(Rgba::rgb(1, 2, 3));
let mod_half = base().with(ColorTransform::AlphaMod(50000));
assert!((mod_half.alpha() - 0.5).abs() < 1e-9, "{}", mod_half.alpha());
let off = base().with(ColorTransform::AlphaOff(-50000));
assert!((off.alpha() - 0.5).abs() < 1e-9, "{}", off.alpha());
let both = base().with(ColorTransform::Alpha(40000)).with(ColorTransform::AlphaMod(50000));
assert!((both.alpha() - 0.2).abs() < 1e-9, "{}", both.alpha());
}
+34 -3
View File
@@ -242,6 +242,8 @@ pub struct ParaProps {
pub bullet_color_follow_text: Option<bool>, pub bullet_color_follow_text: Option<bool>,
/// Bullet size as a fraction of the text size. /// Bullet size as a fraction of the text size.
pub bullet_size: Option<f64>, pub bullet_size: Option<f64>,
/// Absolute bullet font size in points, independent of the paragraph's run size.
pub bullet_size_points: Option<f64>,
pub bullet_size_follow_text: Option<bool>, pub bullet_size_follow_text: Option<bool>,
pub tabs: Option<Vec<TabStop>>, pub tabs: Option<Vec<TabStop>>,
pub default_tab: Option<f64>, pub default_tab: Option<f64>,
@@ -286,11 +288,13 @@ impl ParaProps {
self.bullet_color.clone_from(&p.bullet_color); self.bullet_color.clone_from(&p.bullet_color);
} }
} }
if self.bullet_size_follow_text == Some(false) || self.bullet_size.is_some() { if self.bullet_size_follow_text == Some(false) || self.bullet_size.is_some() || self.bullet_size_points.is_some() {
self.bullet_size_follow_text = None; self.bullet_size_follow_text = None;
} }
if self.bullet_size.is_none() && self.bullet_size_follow_text.is_none() { if self.bullet_size.is_none() && self.bullet_size_points.is_none() && self.bullet_size_follow_text.is_none() {
if p.bullet_size_follow_text == Some(true) { if p.bullet_size_points.is_some() {
self.bullet_size_points = p.bullet_size_points;
} else if p.bullet_size_follow_text == Some(true) {
self.bullet_size_follow_text = Some(true); self.bullet_size_follow_text = Some(true);
} else { } else {
self.bullet_size = p.bullet_size; self.bullet_size = p.bullet_size;
@@ -310,6 +314,33 @@ impl ParaProps {
} }
} }
#[cfg(test)]
mod bullet_size_tests {
use super::ParaProps;
#[test]
fn bullet_size_inheritance_keeps_percentage_and_points_as_one_mode() {
let parent = ParaProps { bullet_size_points: Some(12.0), ..Default::default() };
let mut child = ParaProps { bullet_size: Some(2.0), ..Default::default() };
child.inherit(&parent);
assert_eq!(child.bullet_size, Some(2.0));
assert_eq!(child.bullet_size_points, None);
let parent = ParaProps { bullet_size: Some(1.5), ..Default::default() };
let mut child = ParaProps { bullet_size_points: Some(14.0), ..Default::default() };
child.inherit(&parent);
assert_eq!(child.bullet_size, None);
assert_eq!(child.bullet_size_points, Some(14.0));
let parent = ParaProps { bullet_size_points: Some(12.0), ..Default::default() };
let mut child = ParaProps { bullet_size_follow_text: Some(true), ..Default::default() };
child.inherit(&parent);
assert_eq!(child.bullet_size, None);
assert_eq!(child.bullet_size_points, None);
assert_eq!(child.bullet_size_follow_text, Some(true));
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum Caps { pub enum Caps {
+6 -1
View File
@@ -90,11 +90,16 @@ fn base_class(c: char) -> Class {
c if c.is_alphanumeric() => Class::Word, c if c.is_alphanumeric() => Class::Word,
c if is_symbol(c) => Class::Symbol, c if is_symbol(c) => Class::Symbol,
// A combining mark with no base (or any other mark) stays with letters. // A combining mark with no base (or any other mark) stays with letters.
'\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}' => Class::Word, c if is_combining_mark(c) => Class::Word,
_ => Class::Punct, _ => Class::Punct,
} }
} }
/// Whether `c` is a combining mark (a decomposed accent), which belongs to the letter before it.
pub fn is_combining_mark(c: char) -> bool {
matches!(c, '\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}')
}
fn clusters(text: &str) -> Vec<Cluster> { fn clusters(text: &str) -> Vec<Cluster> {
let mut out: Vec<Cluster> = Vec::new(); let mut out: Vec<Cluster> = Vec::new();
let mut firsts: Vec<char> = Vec::new(); let mut firsts: Vec<char> = Vec::new();
+2 -1
View File
@@ -202,7 +202,8 @@ fn unsafe_url_links_are_not_exported() {
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF /// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
/// embedding. No font bytes or vendor assets are stored as test fixtures. /// embedding. No font bytes or vendor assets are stored as test fixtures.
fn font_without_head() -> Vec<u8> { fn font_without_head() -> Vec<u8> {
let mut bytes = FontDb::global().face("Ubuntu", "Regular").data().to_vec(); // The final bundled face is always Ubuntu Light; a system lookup may select a variable font.
let mut bytes = deckcraft_fonts::bundled().last().unwrap().to_vec();
for (old, new) in [(b"Ubuntu".as_slice(), b"BadPDF".as_slice()), (b"\0U\0b\0u\0n\0t\0u".as_slice(), b"\0B\0a\0d\0P\0D\0F".as_slice())] { for (old, new) in [(b"Ubuntu".as_slice(), b"BadPDF".as_slice()), (b"\0U\0b\0u\0n\0t\0u".as_slice(), b"\0B\0a\0d\0P\0D\0F".as_slice())] {
let mut offset = 0; let mut offset = 0;
while offset + old.len() <= bytes.len() { while offset + old.len() <= bytes.len() {
+1
View File
@@ -14,6 +14,7 @@ mod maps;
mod opc; mod opc;
mod png; mod png;
pub(crate) mod read; pub(crate) mod read;
pub use read::MAX_TAG_BYTES;
mod tables; mod tables;
mod write; mod write;
mod xml; mod xml;
+66 -6
View File
@@ -40,6 +40,12 @@ pub struct Imp<'a> {
pub kept_sidecar_bytes: usize, pub kept_sidecar_bytes: usize,
/// Cached kept sidecars by normalized chart part name. /// Cached kept sidecars by normalized chart part name.
pub kept_charts: HashMap<String, deckcraft_model::chart::ChartKept>, pub kept_charts: HashMap<String, deckcraft_model::chart::ChartKept>,
/// Add-in tags parts, parsed once each, by normalized part name.
pub tags_by_part: HashMap<String, Arc<[(String, String)]>>,
/// Bytes of tags copied into the model so far (see [`MAX_TAG_BYTES`]).
pub tag_bytes: usize,
/// The tag budget ran out and the warning was given.
pub tags_capped: bool,
} }
impl Imp<'_> { impl Imp<'_> {
@@ -196,16 +202,64 @@ fn choice_has_model3d(el: &El, rels: &Rels) -> bool {
rec(el, rels, 0) rec(el, rels, 0)
} }
/// Bytes of tags one import copies into the model, every tags part and reference together: a
/// small compressed tags part listed by many shapes, or many times by one, must not grow without
/// bound (#1164). The package budgets (`opc::MAX_XML_PART`, `MAX_TOTAL`, #1283) bound what is
/// parsed; this bounds what is copied from it.
pub const MAX_TAG_BYTES: usize = 64 << 20;
/// What each copied pair costs besides its text, so empty pairs count too.
const TAG_PAIR_COST: usize = std::mem::size_of::<(String, String)>();
/// Add-in tags named by `holder`'s `p:custDataLst`: the name/value pairs of each tags part. /// Add-in tags named by `holder`'s `p:custDataLst`: the name/value pairs of each tags part.
pub fn tags(imp: &Imp, part: &Part, holder: &El) -> Vec<(String, String)> { /// Each part is parsed once per import and counted once per holder. Once [`MAX_TAG_BYTES`] is
/// reached, no further tag parts are parsed or cached (#1164).
pub fn tags(imp: &mut Imp, part: &Part, holder: &El) -> Vec<(String, String)> {
if imp.tags_capped {
return vec![];
}
let Some(list) = holder.child("custDataLst") else { return vec![] }; let Some(list) = holder.child("custDataLst") else { return vec![] };
let mut out = Vec::new(); let mut out = Vec::new();
let mut seen = HashSet::new();
for t in list.children_named("tags").take(100) { for t in list.children_named("tags").take(100) {
let Some(doc) = t.attr("r:id").and_then(|id| part.rels.get(id)).and_then(|r| imp.pkg.xml(&r.target)) else { continue }; if imp.tags_capped {
for tag in doc.root.children_named("tag").take(10_000) { return out;
if let Some(name) = tag.attr("name") { }
out.push((name.to_string(), tag.attr("val").unwrap_or("").to_string())); let Some(target) = t.attr("r:id").and_then(|id| part.rels.get(id)).map(|r| opc::norm(&r.target)) else { continue };
if !seen.insert(target.clone()) {
continue;
}
let parsed = match imp.tags_by_part.get(&target) {
Some(p) => p.clone(),
None => {
if imp.tags_capped {
return out;
}
let p: Arc<[(String, String)]> = imp
.pkg
.xml(&target)
.map(|doc| {
doc.root
.children_named("tag")
.take(10_000)
.filter_map(|tag| Some((tag.attr("name")?.to_string(), tag.attr("val").unwrap_or("").to_string())))
.collect()
})
.unwrap_or_default();
imp.tags_by_part.insert(target, p.clone());
p
} }
};
for (name, val) in parsed.iter() {
let n = imp.tag_bytes.saturating_add(TAG_PAIR_COST + name.len() + val.len());
if n > MAX_TAG_BYTES {
if !imp.tags_capped {
log::warn!("pptx: add-in tags over {MAX_TAG_BYTES} bytes; the rest are dropped");
imp.tags_capped = true;
}
return out;
}
imp.tag_bytes = n;
out.push((name.clone(), val.clone()));
} }
} }
out out
@@ -389,6 +443,9 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
vml_previews: HashMap::new(), vml_previews: HashMap::new(),
kept_sidecar_bytes: 0, kept_sidecar_bytes: 0,
kept_charts: HashMap::new(), kept_charts: HashMap::new(),
tags_by_part: HashMap::new(),
tag_bytes: 0,
tags_capped: false,
}; };
let part = Part::new(&pkg, &theme_path, &doc); let part = Part::new(&pkg, &theme_path, &doc);
let theme = theme::read_theme(&mut imp, &part, &doc.root); let theme = theme::read_theme(&mut imp, &part, &doc.root);
@@ -427,6 +484,9 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
vml_previews: HashMap::new(), vml_previews: HashMap::new(),
kept_sidecar_bytes: 0, kept_sidecar_bytes: 0,
kept_charts: HashMap::new(), kept_charts: HashMap::new(),
tags_by_part: HashMap::new(),
tag_bytes: 0,
tags_capped: false,
}; };
// Slide ids first so hyperlinks anywhere can point at slides. // Slide ids first so hyperlinks anywhere can point at slides.
let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides); let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides);
@@ -441,7 +501,7 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
let layout_by_part = read_masters(&mut imp, &mut p, &master_paths, &docs); let layout_by_part = read_masters(&mut imp, &mut p, &master_paths, &docs);
let file_slide_ids = read_slides(&mut imp, &mut p, &pres_part, &docs, &slide_ids, &layout_by_part); let file_slide_ids = read_slides(&mut imp, &mut p, &pres_part, &docs, &slide_ids, &layout_by_part);
read_sections(&mut p, &pres_part, pres_el, &file_slide_ids); read_sections(&mut p, &pres_part, pres_el, &file_slide_ids);
p.tags = tags(&imp, &pres_part, pres_el); p.tags = tags(&mut imp, &pres_part, pres_el);
read_custom_shows(&mut p, &imp, &pres_part, pres_el); read_custom_shows(&mut p, &imp, &pres_part, pres_el);
read_guides_and_show(&mut p, &imp, &pres_part); read_guides_and_show(&mut p, &imp, &pres_part);
read_notes_master(&mut imp, &mut p, &pres_part); read_notes_master(&mut imp, &mut p, &pres_part);
+40 -2
View File
@@ -23,6 +23,16 @@ pub fn pct(e: &El, name: &str) -> Option<f64> {
e.f64(name).map(|v| v / 100_000.0) e.f64(name).map(|v| v / 100_000.0)
} }
fn bullet_size_points(value: Option<&str>) -> Option<f64> {
value.and_then(|v| v.trim().parse::<u32>().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0)
}
fn bullet_size_percent(value: Option<&str>) -> Option<f64> {
let value = value?.trim();
let (value, scale) = if let Some(value) = value.strip_suffix('%') { (value.trim(), 100.0) } else { (value, 100_000.0) };
value.parse::<f64>().ok().filter(|v| v.is_finite()).map(|v| v / scale)
}
/// 1/60 000 degree → degrees. /// 1/60 000 degree → degrees.
pub fn ang(e: &El, name: &str) -> Option<f64> { pub fn ang(e: &El, name: &str) -> Option<f64> {
e.f64(name).map(|v| v / 60_000.0) e.f64(name).map(|v| v / 60_000.0)
@@ -454,9 +464,22 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
"buFont" => pp.bullet_font = e.attr("typeface").map(String::from), "buFont" => pp.bullet_font = e.attr("typeface").map(String::from),
"buFontTx" => pp.bullet_font_follow_text = Some(true), "buFontTx" => pp.bullet_font_follow_text = Some(true),
"buClr" => pp.bullet_color = color(e), "buClr" => pp.bullet_color = color(e),
"buSzPct" => {
pp.bullet_size_points = None;
pp.bullet_size_follow_text = None;
pp.bullet_size = bullet_size_percent(e.attr("val")).map(|v| v.clamp(0.25, 4.0));
}
"buSzPts" => {
pp.bullet_size = None;
pp.bullet_size_follow_text = None;
pp.bullet_size_points = bullet_size_points(e.attr("val"));
}
"buClrTx" => pp.bullet_color_follow_text = Some(true), "buClrTx" => pp.bullet_color_follow_text = Some(true),
"buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)), "buSzTx" => {
"buSzTx" => pp.bullet_size_follow_text = Some(true), pp.bullet_size = None;
pp.bullet_size_points = None;
pp.bullet_size_follow_text = Some(true);
}
"tabLst" => { "tabLst" => {
pp.tabs = Some( pp.tabs = Some(
e.children_named("tab") e.children_named("tab")
@@ -692,6 +715,21 @@ mod tests {
assert_eq!(color(&d.root).unwrap().base, ColorBase::Rgb { rgb: Rgba::rgb(255, 0, 0) }); assert_eq!(color(&d.root).unwrap().base, ColorBase::Rgb { rgb: Rgba::rgb(255, 0, 0) });
} }
#[test]
fn bullet_point_size_trims_schema_whitespace() {
let d = parse(br#"<a:buSzPts xmlns:a="x" val=" 1400 "/>"#).unwrap();
assert_eq!(bullet_size_points(d.root.attr("val")), Some(14.0));
assert_eq!(bullet_size_points(Some("1400")), Some(14.0));
}
#[test]
fn bullet_percentage_size_accepts_numeric_and_percent_forms() {
assert_eq!(bullet_size_percent(Some("100000")), Some(1.0));
assert_eq!(bullet_size_percent(Some("100%")), Some(1.0));
assert_eq!(bullet_size_percent(Some(" 125% ")), Some(1.25));
assert_eq!(bullet_size_percent(Some("invalid")), None);
}
#[test] #[test]
fn miter_limit_accepts_both_percentage_forms_with_schema_bounds() { fn miter_limit_accepts_both_percentage_forms_with_schema_bounds() {
assert_eq!(parse_miter_limit("200000"), Some(200_000)); assert_eq!(parse_miter_limit("200000"), Some(200_000));
+4 -1
View File
@@ -543,6 +543,7 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|| p.bullet_color.is_some() || p.bullet_color.is_some()
|| p.bullet_color_follow_text.is_some() || p.bullet_color_follow_text.is_some()
|| p.bullet_size.is_some() || p.bullet_size.is_some()
|| p.bullet_size_points.is_some()
|| p.bullet_size_follow_text.is_some() || p.bullet_size_follow_text.is_some()
|| p.tabs.is_some() || p.tabs.is_some()
|| def.is_some(); || def.is_some();
@@ -567,8 +568,10 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
color(w, c); color(w, c);
w.close("a:buClr"); w.close("a:buClr");
} }
if p.bullet_size.is_none() && p.bullet_size_follow_text == Some(true) { if p.bullet_size.is_none() && p.bullet_size_points.is_none() && p.bullet_size_follow_text == Some(true) {
w.empty0("a:buSzTx"); w.empty0("a:buSzTx");
} else if let Some(s) = p.bullet_size_points.filter(|s| s.is_finite() && (1.0..=4000.0).contains(s)) {
w.val("a:buSzPts", ((s * 100.0).round() as u32).to_string());
} else if let Some(s) = p.bullet_size { } else if let Some(s) = p.bullet_size {
w.val("a:buSzPct", pct(s.clamp(0.25, 4.0))); w.val("a:buSzPct", pct(s.clamp(0.25, 4.0)));
} }
+27
View File
@@ -490,6 +490,33 @@ fn shapes_fills_lines_effects_round_trip() {
assert_eq!(by_name(&q.slides[1], "Group").id, by_name(&p.slides[1], "Group").id); assert_eq!(by_name(&q.slides[1], "Group").id, by_name(&p.slides[1], "Group").id);
} }
#[test]
fn bullet_size_in_points_survives_two_pptx_round_trips() {
let mut p = Presentation::default();
let mut textbox = shape(&mut p, "Point-sized bullet", 20.0, 20.0, 400.0, 100.0);
textbox.text = Some(TextBody {
paragraphs: [12.0, 500.0, 4000.0]
.into_iter()
.map(|size| Paragraph {
props: deckcraft_model::text::ParaProps {
bullet: Some(Bullet::Char { char: "•".into() }),
bullet_size_points: Some(size),
..Default::default()
},
runs: vec![Run::with("Bullet text", RunProps::default())],
..Default::default()
})
.collect(),
..Default::default()
});
Arc::make_mut(&mut p.slides[0]).shapes.push(textbox);
let q = round(&round(&p));
let props = &by_name(&q.slides[0], "Point-sized bullet").text.as_ref().expect("text").paragraphs;
assert_eq!(props.iter().map(|p| p.props.bullet_size_points).collect::<Vec<_>>(), vec![Some(12.0), Some(500.0), Some(4000.0)]);
assert!(props.iter().all(|p| p.props.bullet_size.is_none()));
}
#[test] #[test]
fn line_miter_limit_survives_two_pptx_round_trips() { fn line_miter_limit_survives_two_pptx_round_trips() {
let mut p = Presentation::default(); let mut p = Presentation::default();
+108
View File
@@ -36,3 +36,111 @@ fn tags_survive_round_trip() {
assert_eq!(first.tags, vec![t("THINKCELLSHAPEDONOTDELETE", "t!abc")]); assert_eq!(first.tags, vec![t("THINKCELLSHAPEDONOTDELETE", "t!abc")]);
assert!(back.slides.iter().skip(1).all(|s| s.tags.is_empty())); assert!(back.slides.iter().skip(1).all(|s| s.tags.is_empty()));
} }
/// #1164: the same tags part listed over and over is read once, and the tags one import copies
/// into the model are capped however often a shape lists them.
#[test]
fn a_tags_part_listed_many_times_is_copied_once_and_capped() {
let mut s = Session::new();
deckcraft_engine::sample::open_sample(&mut s).expect("sample");
for _ in 0..6 {
s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [10, 10, 50, 50]})).expect("shape");
}
let mut p = s.doc().expect("doc").doc.as_ref().clone();
let slide = Arc::make_mut(&mut p.slides[0]);
// A quarter of the budget with its name and pair cost: the fifth shape listing it goes over.
let pair = std::mem::size_of::<(String, String)>();
slide.shapes[0].tags = vec![t("BIG", &"x".repeat(deckcraft_pptx::MAX_TAG_BYTES / 4 - 3 - pair))];
let bytes = deckcraft_pptx::export(&p).expect("export");
// Every shape on the first slide lists that one tags part 100 times.
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut xml = String::new();
zip.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("read");
let start = xml.find("<p:custDataLst>").expect("tags listed");
let end = xml[start..].find("</p:custDataLst>").expect("list ends") + start + "</p:custDataLst>".len();
let list = &xml[start..end];
let inner = list.trim_start_matches("<p:custDataLst>").trim_end_matches("</p:custDataLst>");
let many = format!("<p:custDataLst>{}</p:custDataLst>", inner.repeat(100));
let xml = xml.replace(list, "").replace("<p:nvPr/>", "<p:nvPr></p:nvPr>").replace("<p:nvPr>", &format!("<p:nvPr>{many}"));
assert!(xml.matches(&many).count() >= 5, "enough shapes to go over the budget");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..zip.len() {
let mut f = zip.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = vec![];
f.read_to_end(&mut data).expect("read");
out.start_file(&name, zip::write::SimpleFileOptions::default()).expect("start");
std::io::Write::write_all(&mut out, if name == "ppt/slides/slide1.xml" { xml.as_bytes() } else { &data }).expect("write");
}
let bytes = out.finish().expect("finish").into_inner();
let back = deckcraft_pptx::import(&bytes).expect("import");
let tagged: Vec<_> = back.slides[0].shapes.iter().filter(|x| !x.tags.is_empty()).collect();
assert_eq!(tagged.len(), 4, "four copies fit the budget");
assert!(tagged.iter().all(|x| x.tags.len() == 1), "each shape keeps the part's tag once, not 100 times");
}
/// #1164: empty tags cost something too, or a part of 10,000 empty pairs listed by every shape
/// would be copied without ever reaching the budget.
#[test]
fn empty_tags_count_against_the_budget() {
let pair = std::mem::size_of::<(String, String)>();
let per_shape = 10_000 * pair;
let shapes = deckcraft_pptx::MAX_TAG_BYTES / per_shape + 2;
let mut s = Session::new();
deckcraft_engine::sample::open_sample(&mut s).expect("sample");
let mut p = s.doc().expect("doc").doc.as_ref().clone();
let slide = Arc::make_mut(&mut p.slides[0]);
let template = slide.shapes[0].clone();
slide.shapes.clear();
for i in 0..shapes {
let mut sh = template.clone();
sh.id = deckcraft_model::ShapeId(10_000 + i as u32);
sh.ph = None;
sh.tags = vec![t("", ""); 10_000];
slide.shapes.push(sh);
}
let bytes = deckcraft_pptx::export(&p).expect("export");
let back = deckcraft_pptx::import(&bytes).expect("import");
let pairs: usize = back.slides[0].shapes.iter().map(|x| x.tags.len()).sum();
assert!(pairs * pair <= deckcraft_pptx::MAX_TAG_BYTES, "{pairs} pairs copied");
assert!(pairs < shapes * 10_000, "the budget stopped the copying");
}
/// #1164: once the budget is exhausted, further tags parts are not parsed or cached into
/// `tags_by_part`.
#[test]
fn new_tag_parts_are_not_parsed_or_cached_after_the_budget_is_exhausted() {
let mut s = Session::new();
deckcraft_engine::sample::open_sample(&mut s).expect("sample");
s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [10, 10, 50, 50]})).expect("shape");
let mut p = s.doc().expect("doc").doc.as_ref().clone();
let slide = Arc::make_mut(&mut p.slides[0]);
assert!(slide.shapes.len() >= 2);
// The first shape's tag fits the budget, and a second pair pushes it over the cap.
let pair = std::mem::size_of::<(String, String)>();
slide.shapes[0].tags = vec![t("BIG", &"x".repeat(deckcraft_pptx::MAX_TAG_BYTES - 3 - pair)), t("OVER", "1")];
// The second shape has its own separate tag part.
slide.shapes[1].tags = vec![t("SHAPE2_TAG", "dropped")];
let bytes = deckcraft_pptx::export(&p).expect("export");
// Corrupt the second shape's tag part (`tag2.xml`) with invalid XML in the zip.
// If it were parsed, XML loading would fail. Because the budget was capped, it is never read.
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..zip.len() {
let mut f = zip.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = vec![];
f.read_to_end(&mut data).expect("read");
out.start_file(&name, zip::write::SimpleFileOptions::default()).expect("start");
let payload = if name == "ppt/tags/tag2.xml" { b"<not valid xml<<<<" as &[u8] } else { &data };
std::io::Write::write_all(&mut out, payload).expect("write");
}
let corrupted = out.finish().expect("finish").into_inner();
let back = deckcraft_pptx::import(&corrupted).expect("import succeeds because tag2.xml is not parsed");
assert!(!back.slides[0].shapes[0].tags.is_empty(), "first shape tag was loaded");
assert!(back.slides[0].shapes[1].tags.is_empty(), "second shape tag was dropped and never parsed");
}
+3
View File
@@ -94,6 +94,9 @@ mod tests;
#[cfg(test)] #[cfg(test)]
mod tests_wild; mod tests_wild;
#[cfg(test)]
mod tests_effect_holes;
#[cfg(test)] #[cfg(test)]
mod mt_tests { mod mt_tests {
use super::*; use super::*;
+35 -4
View File
@@ -91,7 +91,10 @@ pub fn fill_path(
let on_path = m * path.clone(); let on_path = m * path.clone();
ctx.set_transform(Affine::IDENTITY); ctx.set_transform(Affine::IDENTITY);
ctx.push_clip_layer(&on_path); ctx.push_clip_layer(&on_path);
picture(ctx, pres, &resolved(rctx, pf, ph), on_slide, Affine::IDENTITY, None); // Drawn in device pixels, so a tile (given in shape units) is sized to pixels first.
let tiled = tiled_per_point(fill, (c[0].hypot(c[1]), c[2].hypot(c[3])));
let upright = if let Fill::Picture(p) = tiled.as_ref() { p } else { pf };
picture(ctx, pres, &resolved(rctx, upright, ph), on_slide, Affine::IDENTITY, None);
ctx.pop_layer(); ctx.pop_layer();
return; return;
} }
@@ -226,9 +229,12 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
} }
let (nw, nh) = images::natural_size(&item.data).unwrap_or((iw, ih)); let (nw, nh) = images::natural_size(&item.data).unwrap_or((iw, ih));
// PowerPoint draws the tile at a whole number of output pixels. // PowerPoint draws the tile at a whole number of output pixels.
let k = px_scale.max(1e-9); // Per axis: a group can stretch its child space unevenly, and a pixel is then a different
let snap = |v: f64| (v * k).round().max(1.0) / k; // number of shape units across and down.
let (tile_w, tile_h) = (snap(nw * 0.75 * sx), snap(nh * 0.75 * sy)); let c = m.as_coeffs();
let (kx, ky) = (c[0].hypot(c[1]).max(1e-9), c[2].hypot(c[3]).max(1e-9));
let snap = |v: f64, k: f64| (v * k).round().max(1.0) / k;
let (tile_w, tile_h) = (snap(nw * 0.75 * sx, kx), snap(nh * 0.75 * sy, ky));
if !(tile_w > 1e-6 && tile_h > 1e-6 && tile_w.is_finite() && tile_h.is_finite()) { if !(tile_w > 1e-6 && tile_h > 1e-6 && tile_w.is_finite() && tile_h.is_finite()) {
return; return;
} }
@@ -260,6 +266,31 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
draw_image(ctx, &image, iw, ih, dest, m, alpha); draw_image(ctx, &image, iw, ih, dest, m, alpha);
} }
/// `fill`, with a tiled picture's tile size and offset moved from points to shape units, given
/// the shape units to the point along x and y. A group's child coordinates scale its shapes'
/// boxes (a group can stretch them unevenly), but a tile keeps its size on the slide as PowerPoint
/// draws it, so in a group with a legacy child space the tile would otherwise cover hundreds of
/// boxes and the fill show one flat texel. Anything but a tiled picture, or a unit scale, is
/// returned as it is.
pub fn tiled_per_point(fill: &Fill, per_point: (f64, f64)) -> std::borrow::Cow<'_, Fill> {
let Fill::Picture(pf) = fill else { return std::borrow::Cow::Borrowed(fill) };
match tiled_picture_per_point(pf, per_point) {
std::borrow::Cow::Borrowed(_) => std::borrow::Cow::Borrowed(fill),
std::borrow::Cow::Owned(p) => std::borrow::Cow::Owned(Fill::Picture(p)),
}
}
/// [`tiled_per_point`] for a picture fill itself (a picture shape's own fill is not a [`Fill`]).
pub fn tiled_picture_per_point(pf: &PictureFill, per_point: (f64, f64)) -> std::borrow::Cow<'_, PictureFill> {
let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return std::borrow::Cow::Borrowed(pf) };
let (kx, ky) = per_point;
if !(kx.is_finite() && ky.is_finite() && kx > 1e-12 && ky > 1e-12) || ((kx - 1.0).abs() < 1e-9 && (ky - 1.0).abs() < 1e-9) {
return std::borrow::Cow::Borrowed(pf);
}
let mode = PictureMode::Tile { tx: tx * kx, ty: ty * ky, sx: sx * kx, sy: sy * ky, flip: flip.clone(), align: align.clone() };
std::borrow::Cow::Owned(PictureFill { mode, ..pf.clone() })
}
/// Where the first tile of a tiled picture fill goes in `bounds`: its corner, edge middle or /// Where the first tile of a tiled picture fill goes in `bounds`: its corner, edge middle or
/// centre named by `align` (`tl`, `t`, `tr`, `l`, `ctr`, `r`, `bl`, `b`, `br`) on the same point /// centre named by `align` (`tl`, `t`, `tr`, `l`, `ctr`, `r`, `bl`, `b`, `br`) on the same point
/// of the box, then moved by the offset (points). /// of the box, then moved by the offset (points).
+62 -15
View File
@@ -346,6 +346,14 @@ impl Renderer {
if k.is_finite() && k > 1e-12 { 1.0 / k } else { 1.0 } if k.is_finite() && k > 1e-12 { 1.0 / k } else { 1.0 }
} }
/// [`Renderer::per_point`] along the shape's own x and y axes: a group can stretch its child
/// space unevenly, and a tiled fill keeps its size in points on the slide on both axes.
fn per_point_xy(&self, m: Affine, depth: usize) -> (f64, f64) {
let c = self.line_space(m, depth).1.as_coeffs();
let inv = |s: f64| if s.is_finite() && s > 1e-12 { 1.0 / s } else { 1.0 };
(inv(c[0].hypot(c[1])), inv(c[2].hypot(c[3])))
}
/// The size and transform a shape's text is set in: inside a group whose child coordinates /// The size and transform a shape's text is set in: inside a group whose child coordinates
/// scale the shape, the shape's size in slide points and `m` with that scale taken out, so /// scale the shape, the shape's size in slide points and `m` with that scale taken out, so
/// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves. /// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves.
@@ -428,7 +436,8 @@ impl Renderer {
// A group's own fill (a group filled with its parent's passes that on). // A group's own fill (a group filled with its parent's passes that on).
let own = match &s.fill { let own = match &s.fill {
Some(Fill::Group) | None => self.group_fill.clone(), Some(Fill::Group) | None => self.group_fill.clone(),
Some(fl) => Some((fl.clone(), m, Rect::new(0.0, 0.0, x.w, x.h))), // A tile is given in points; this group's own space may be scaled by its parents.
Some(fl) => Some((paint::tiled_per_point(fl, self.per_point_xy(m, depth)).into_owned(), m, Rect::new(0.0, 0.0, x.w, x.h))),
}; };
let outer = std::mem::replace(&mut self.group_fill, own); let outer = std::mem::replace(&mut self.group_fill, own);
// A group's outer shadow is cast by everything in it, as PowerPoint draws it: the // A group's outer shadow is cast by everything in it, as PowerPoint draws it: the
@@ -541,6 +550,7 @@ impl Renderer {
geo: &Geometry, geo: &Geometry,
(w, h): (f64, f64), (w, h): (f64, f64),
m: Affine, m: Affine,
per_point: (f64, f64),
fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade, fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade,
) { ) {
for (i, sp) in geo.paths.iter().enumerate() { for (i, sp) in geo.paths.iter().enumerate() {
@@ -566,7 +576,10 @@ impl Renderer {
paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres); paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres);
} }
} }
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres), _ => {
let fl = paint::tiled_per_point(fl, per_point);
paint::fill_path(ctx, rctx, &fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres)
}
} }
ctx.set_fill_rule(peniko::Fill::NonZero); ctx.set_fill_rule(peniko::Fill::NonZero);
} }
@@ -655,9 +668,13 @@ impl Renderer {
let line = &d.line; let line = &d.line;
// Effects below the shape. // Effects below the shape.
if let Some(e) = &effects { if let Some(e) = &effects {
// A picture (or picture fill) casts its shadow from its pixels, not its frame. // A picture (or picture fill) casts its shadow from its pixels, not its frame. A tile
// is sized in points on the slide, as the face's is, and so is its shadow and reflection.
let content = match (&s.kind, &fill) { let content = match (&s.kind, &fill) {
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()), (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => {
let pf = paint::tiled_picture_per_point(pf, self.per_point_xy(m, depth));
Some(paint::resolved(rctx, &pf, None).into_owned())
}
_ => None, _ => None,
}; };
// What casts the shadow: the fill as see-through as it is, and the outline. // What casts the shadow: the fill as see-through as it is, and the outline.
@@ -736,6 +753,7 @@ impl Renderer {
let fill = &d.fill; let fill = &d.fill;
let outline = &d.outline; let outline = &d.outline;
let fade = |i: usize| d.fade(i); let fade = |i: usize| d.fade(i);
let pt_xy = self.per_point_xy(m, d.depth);
// Fill. // Fill.
match &s.kind { match &s.kind {
ShapeKind::Picture { fill: pf } => { ShapeKind::Picture { fill: pf } => {
@@ -744,13 +762,14 @@ impl Renderer {
if s.fill.as_ref().is_some_and(|f| !f.is_none()) if s.fill.as_ref().is_some_and(|f| !f.is_none())
&& let Some(fl) = &fill && let Some(fl) = &fill
{ {
self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade);
} }
ctx.set_transform(m); ctx.set_transform(m);
let bounds = Rect::new(0.0, 0.0, w, h); let bounds = Rect::new(0.0, 0.0, w, h);
ctx.push_clip_layer(outline); ctx.push_clip_layer(outline);
let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1); let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1);
paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); let pf = paint::tiled_picture_per_point(pf, pt_xy);
paint::picture(ctx, f.pres, &paint::resolved(rctx, &pf, None), bounds, m, t);
ctx.pop_layer(); ctx.pop_layer();
} }
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its // An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
@@ -761,7 +780,7 @@ impl Renderer {
if s.fill.as_ref().is_some_and(|f| !f.is_none()) if s.fill.as_ref().is_some_and(|f| !f.is_none())
&& let Some(fl) = &fill && let Some(fl) = &fill
{ {
self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade);
} }
ctx.set_transform(m); ctx.set_transform(m);
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() }; let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
@@ -787,7 +806,7 @@ impl Renderer {
if let Some(fl) = &fill if let Some(fl) = &fill
&& !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none()) && !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none())
{ {
self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade);
} }
} }
} }
@@ -1125,12 +1144,15 @@ impl Renderer {
// see-through as its fill (none for an outline-only shape) … // see-through as its fill (none for an outline-only shape) …
None => { None => {
if let Some(a) = fill_alpha { if let Some(a) = fill_alpha {
let mut filled = BezPath::new();
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) {
filled.extend(sp.path.iter());
}
ctx.set_paint(color(c.with_alpha(255), a)); ctx.set_paint(color(c.with_alpha(255), a));
ctx.fill_path(&filled); // Each subpath under its own fill rule, as the shape itself is
// drawn: one merged path filled NonZero fills the hole of an
// even-odd shape (a donut, a frame) (#1596).
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) {
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
ctx.fill_path(&sp.path);
}
ctx.set_fill_rule(peniko::Fill::NonZero);
} }
} }
} }
@@ -1161,7 +1183,19 @@ impl Renderer {
ctx.stroke_path(&silhouette); ctx.stroke_path(&silhouette);
// A shape without a fill (an outline, a frame) glows along its line only (#225). // A shape without a fill (an outline, a frame) glows along its line only (#225).
if fill_alpha.is_some() || content.is_some() { if fill_alpha.is_some() || content.is_some() {
ctx.fill_path(&silhouette); // Per subpath under its own fill rule, as in the shadow above: the
// merged silhouette filled NonZero fills an even-odd hole (#1596).
// When no subpath takes part the silhouette is the outline fallback.
let mut filled_any = false;
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None || fill.is_none()) {
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
ctx.fill_path(&sp.path);
filled_any = true;
}
if !filled_any {
ctx.fill_path(&silhouette);
}
ctx.set_fill_rule(peniko::Fill::NonZero);
} }
ctx.pop_layer(); ctx.pop_layer();
} }
@@ -1180,7 +1214,20 @@ impl Renderer {
} }
None => { None => {
if let Some(fl) = fill { if let Some(fl) = fill {
paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres); // Per subpath under its own fill rule, as in the shadow above:
// the merged silhouette filled NonZero fills an even-odd hole
// (#1596). The outline fallback covers shapes with no
// participating subpath.
let mut filled_any = false;
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None || fill.is_none()) {
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
paint::fill_path(ctx, rctx, fl, ph, &sp.path, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres);
filled_any = true;
}
if !filled_any {
paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres);
}
ctx.set_fill_rule(peniko::Fill::NonZero);
} }
} }
} }
+50
View File
@@ -515,6 +515,56 @@ fn glow_of_a_shape_with_no_fill_and_no_line_is_not_drawn() {
} }
} }
#[test]
fn shadow_and_glow_keep_an_even_odd_shapes_hole() {
// #1596: a donut is one even-odd subpath — its hole exists only under the
// even-odd rule. The effects were cast from a merged silhouette filled
// NonZero, so the shadow and the glow filled the hole solid. The hole's
// centre must stay the slide's white in both, while the effects still draw.
let donut = |effects: deckcraft_model::Effects| {
let mut sh = Shape {
id: ShapeId(612),
xfrm: Some(Xfrm::new(50.0, 50.0, 200.0, 200.0)),
geom: Geom::preset("donut"),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 160, 0)))),
..Default::default()
};
sh.effects = Some(effects);
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
};
let img = donut(deckcraft_model::Effects {
outer_shadow: Some(deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
blur: 8.0,
dist: 0.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
}),
..Default::default()
});
let hole = img.pixel(150, 150);
assert!(hole[0] > 245 && hole[1] > 245 && hole[2] > 245, "shadow in the hole: {hole:?}");
// The shadow still falls around the shape (just below its bottom edge).
let below = img.pixel(150, 251);
assert!(below[0] < 200 && below[0] == below[2], "no shadow below: {below:?}");
let img = donut(deckcraft_model::Effects {
glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)), radius: 8.0 }),
..Default::default()
});
let hole = img.pixel(150, 150);
assert!(hole[0] > 245 && hole[1] > 245 && hole[2] > 245, "glow in the hole: {hole:?}");
// The ring itself is the shape's orange.
let ring = img.pixel(150, 75);
assert!(ring[0] > 200 && ring[1] > 100 && ring[2] < 100, "ring: {ring:?}");
}
#[test] #[test]
fn placeholder_prompts_only_in_edit_view() { fn placeholder_prompts_only_in_edit_view() {
let p = Presentation::default(); let p = Presentation::default();
+196
View File
@@ -0,0 +1,196 @@
//! Contributor-original pixel goldens for #1596 and the review of #1626.
//! The fixed samples cover background, hole/centre, ring, visible halos and reflected coverage.
use super::{Fill, Image, RenderOpts, Rgba, render_slide};
use deckcraft_model::style::{ColorRef, Effects, Glow, Line, Reflection, Shadow};
use deckcraft_model::{Geom, Presentation, Shape, ShapeId, Xfrm};
const PROBES: [(u32, u32); 9] = [
(20, 20), // Clear background.
(130, 130), // Clear donut hole; filled ellipse centre.
(130, 70), // Original ring/ellipse fill.
(130, 47), // Visible shadow/glow above the outer edge.
(130, 213), // Visible shadow/glow below the outer edge.
(130, 93), // Shadow/glow along the inner rim, inside the hole.
(130, 230), // Reflected upper ring.
(130, 300), // Reflected hole/ellipse centre.
(70, 300), // Reflected ring at the same fade as the hole.
];
#[derive(Clone, Copy, Debug)]
enum Effect {
Shadow,
Glow,
Reflection,
}
fn scene(shape: &str, effect: Effect) -> Presentation {
let mut p = Presentation::default();
let mut effects = Effects::default();
match effect {
Effect::Shadow => {
effects.outer_shadow = Some(Shadow {
color: ColorRef::rgb(Rgba::BLACK),
blur: 8.0,
dist: 0.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
})
}
Effect::Glow => effects.glow = Some(Glow { color: ColorRef::rgb(Rgba::rgb(255, 0, 0)), radius: 8.0 }),
Effect::Reflection => effects.reflection = Some(Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 1.0, dist: 10.0 }),
}
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
slide.background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(ColorRef::rgb(Rgba::WHITE)) });
slide.shapes = vec![Shape {
id: ShapeId(1596),
xfrm: Some(Xfrm::new(50.0, 50.0, 160.0, 160.0)),
geom: Geom::preset(shape),
fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(32, 96, 224)))),
line: Some(Line::none()),
effects: Some(effects),
..Default::default()
}];
p
}
fn draw(shape: &str, effect: Effect) -> Image {
let p = scene(shape, effect);
let image = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
if let Ok(dir) = std::env::var("DECKCRAFT_EFFECT_HOLES_QA") {
// Overwrite, so a QA directory can be reused across runs.
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(std::path::Path::new(&dir).join(format!("{shape}-{effect:?}.png")), image.to_png()).unwrap();
}
image
}
// Fixed contributor-original RGBA goldens; tolerate one final quantization step.
// The nonwhite halo/reflection samples also fail if an effect is disabled outright.
fn assert_golden(shape: &str, effect: Effect, expected: [[u8; 4]; 9]) {
let image = draw(shape, effect);
for ((x, y), want) in PROBES.into_iter().zip(expected) {
let got = image.pixel(x, y);
assert!(got.iter().zip(want).all(|(a, b)| (*a as i32 - b as i32).abs() <= 1), "{shape} {effect:?} at {x},{y}: {got:?} vs golden {want:?}");
}
}
#[test]
fn donut_shadow_matches_golden() {
assert_golden(
"donut",
Effect::Shadow,
[
[255, 255, 255, 255],
[255, 255, 255, 255],
[32, 96, 224, 255],
[214, 214, 214, 255],
[233, 233, 233, 255],
[227, 227, 227, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
],
);
}
#[test]
fn donut_glow_matches_golden() {
assert_golden(
"donut",
Effect::Glow,
[
[255, 255, 255, 255],
[255, 255, 255, 255],
[32, 96, 224, 255],
[255, 49, 49, 255],
[255, 111, 111, 255],
[255, 96, 96, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
],
);
}
#[test]
fn donut_reflection_matches_golden() {
assert_golden(
"donut",
Effect::Reflection,
[
[255, 255, 255, 255],
[255, 255, 255, 255],
[32, 96, 224, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[130, 166, 238, 255],
[255, 255, 255, 255],
[189, 208, 246, 255],
],
);
}
#[test]
fn ellipse_shadow_matches_golden() {
assert_golden(
"ellipse",
Effect::Shadow,
[
[255, 255, 255, 255],
[32, 96, 224, 255],
[32, 96, 224, 255],
[214, 214, 214, 255],
[233, 233, 233, 255],
[32, 96, 224, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
],
);
}
#[test]
fn ellipse_glow_matches_golden() {
assert_golden(
"ellipse",
Effect::Glow,
[
[255, 255, 255, 255],
[32, 96, 224, 255],
[32, 96, 224, 255],
[255, 49, 49, 255],
[255, 111, 111, 255],
[32, 96, 224, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
],
);
}
#[test]
fn ellipse_reflection_matches_golden() {
assert_golden(
"ellipse",
Effect::Reflection,
[
[255, 255, 255, 255],
[32, 96, 224, 255],
[32, 96, 224, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[32, 96, 224, 255],
[130, 166, 238, 255],
[189, 208, 246, 255],
[189, 208, 246, 255],
],
);
}
+192
View File
@@ -454,6 +454,198 @@ fn tiled_pictures_flip_align_and_skip_non_positive_scales() {
assert!(close(img.pixel(115, 115), WHITE, 5) && close(img.pixel(300, 150), WHITE, 5)); assert!(close(img.pixel(115, 115), WHITE, 5) && close(img.pixel(300, 150), WHITE, 5));
} }
#[allow(clippy::unwrap_used)]
fn red_over_blue() -> Vec<u8> {
let img = image::RgbImage::from_fn(4, 8, |_, y| if y < 4 { image::Rgb([255, 0, 0]) } else { image::Rgb([0, 0, 255]) });
let mut out = std::io::Cursor::new(vec![]);
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
#[test]
fn a_tile_keeps_its_size_in_a_group_with_scaled_child_coordinates() {
use deckcraft_model::style::{PictureFill, PictureMode};
// A group's child space scales its shapes' boxes, not the tile: the same tiles show whether
// the group's child space is 1600 times smaller (old decks write them so), 4 times larger,
// the slide's own or stretched unevenly (PowerPoint draws all of them alike, measured on its
// own export of a probe deck). Before, one tile covered hundreds of boxes and the fill was one
// flat texel, and an uneven group stretched the tile.
// Wide: 8x4 px = 6x3 pt, ten times = 60x30 pt tiles, red then blue across.
// Tall: 4x8 px = 3x6 pt, ten times = 30x60 pt tiles, red over blue down.
let blue = Rgba::rgb(0, 0, 255);
let scales = [(1.0, 1.0), (1600.0, 1600.0), (0.25, 0.25), (2.0, 0.5), (0.5, 2.0), (1800.0, 1.0), (1.0, 1800.0)];
for (kx, ky) in scales {
for tall in [false, true] {
let mut p = Presentation::default();
let png = if tall { red_over_blue() } else { half_red_half_blue() };
let media = p.add_media("t.png", "image/png", png).expect("media");
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
let member = Shape {
id: ShapeId(22),
xfrm: Some(Xfrm::new(0.0, 0.0, 240.0 / kx, 60.0 / ky)),
fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })),
line: no_line(),
..Default::default()
};
let group = Shape {
id: ShapeId(21),
xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)),
kind: ShapeKind::Group { children: vec![member], child: Xfrm::new(0.0, 0.0, 240.0 / kx, 60.0 / ky) },
..Default::default()
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![group];
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Along the tile's long side: first half red, second blue, then red again.
let (a, b, c) = if tall { ((115, 115), (115, 145), (115, 175)) } else { ((115, 115), (145, 115), (175, 115)) };
let (pa, pb, pc) = (img.pixel(a.0, a.1), img.pixel(b.0, b.1), img.pixel(c.0, c.1));
assert!(
close(pa, RED, 30) && close(pb, blue, 30) && (tall || close(pc, RED, 30)) && (!tall || close(pc, WHITE, 5) || close(pc, RED, 30)),
"child space {kx} x {ky}, tall={tall}: {pa:?} {pb:?} {pc:?}"
);
}
}
}
/// A slide with `shape` (built for a box of `w` x `h` child units) in a group at (100, 100) that is
/// 240 x 60 pt on the slide and scaled `kx` x `ky` against its child space, drawn at 1 px per point.
fn in_a_scaled_group(kx: f64, ky: f64, p: Presentation, shape: impl FnOnce(f64, f64) -> Shape) -> Image {
let (w, h) = (240.0 / kx, 60.0 / ky);
let group = Shape {
id: ShapeId(21),
xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)),
kind: ShapeKind::Group { children: vec![shape(w, h)], child: Xfrm::new(0.0, 0.0, w, h) },
..Default::default()
};
let mut p = p;
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![group];
render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() })
}
#[test]
fn a_tile_a_group_passes_to_its_members_keeps_its_size_inside_a_scaled_group() {
use deckcraft_model::style::{PictureFill, PictureMode};
// The group that owns the tiled fill sits inside another group whose child space is scaled: the
// members that take it (`grpFill`) get tiles of 60 x 30 pt, as with no outer group.
let blue = Rgba::rgb(0, 0, 255);
for (kx, ky) in [(1.0, 1.0), (0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] {
let mut p = Presentation::default();
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
let img = in_a_scaled_group(kx, ky, p, |w, h| {
let member =
Shape { id: ShapeId(23), xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), fill: Some(Fill::Group), line: no_line(), ..Default::default() };
Shape {
id: ShapeId(22),
xfrm: Some(Xfrm::new(0.0, 0.0, w, h)),
fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })),
kind: ShapeKind::Group { children: vec![member], child: Xfrm::new(0.0, 0.0, w, h) },
..Default::default()
}
});
let (a, b, c) = (img.pixel(115, 115), img.pixel(145, 115), img.pixel(175, 115));
assert!(close(a, RED, 30) && close(b, blue, 30) && close(c, RED, 30), "outer group {kx} x {ky}: {a:?} {b:?} {c:?}");
}
}
#[test]
fn a_picture_shapes_own_tile_keeps_its_size_inside_a_scaled_group() {
use deckcraft_model::style::{PictureFill, PictureMode};
// A picture (`p:pic`) whose blip fill is a tile rather than a stretch.
let blue = Rgba::rgb(0, 0, 255);
for (kx, ky) in [(1.0, 1.0), (0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] {
let mut p = Presentation::default();
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
let img = in_a_scaled_group(kx, ky, p, |w, h| Shape {
id: ShapeId(22),
xfrm: Some(Xfrm::new(0.0, 0.0, w, h)),
kind: ShapeKind::Picture { fill: PictureFill { media, mode, ..Default::default() } },
line: no_line(),
..Default::default()
});
let (a, b, c) = (img.pixel(115, 115), img.pixel(145, 115), img.pixel(175, 115));
assert!(close(a, RED, 30) && close(b, blue, 30) && close(c, RED, 30), "group {kx} x {ky}: {a:?} {b:?} {c:?}");
}
}
#[test]
fn a_tiled_pictures_reflection_keeps_the_tile_size_inside_a_scaled_group() {
use deckcraft_model::style::{PictureFill, PictureMode, Reflection};
// The reflection mirrors the picture's pixels, so its tiles must be the face's, 60 x 30 pt,
// whatever the group's child scale: just below the box it shows the same colours.
let reflected = |kx: f64, ky: f64| {
let mut p = Presentation::default();
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
let rf = Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0 };
let img = in_a_scaled_group(kx, ky, p, |w, h| Shape {
id: ShapeId(22),
xfrm: Some(Xfrm::new(0.0, 0.0, w, h)),
kind: ShapeKind::Picture { fill: PictureFill { media, mode, ..Default::default() } },
line: no_line(),
effects: Some(deckcraft_model::Effects { reflection: Some(rf), ..Default::default() }),
..Default::default()
});
[img.pixel(115, 163), img.pixel(145, 163), img.pixel(175, 163)]
};
let plain = reflected(1.0, 1.0);
assert!(plain[0][0] > plain[0][2] && plain[1][2] > plain[1][0] && plain[2][0] > plain[2][2], "red, blue, red in the reflection: {plain:?}");
for (kx, ky) in [(0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] {
let got = reflected(kx, ky);
for (g, e) in got.iter().zip(&plain) {
assert!(close(*g, Rgba::rgb(e[0], e[1], e[2]), 40), "child scale {kx} x {ky}: {got:?} against {plain:?}");
}
}
}
#[test]
fn an_upright_tile_in_a_turned_shape_keeps_its_size_in_points_at_any_zoom() {
use deckcraft_model::style::{PictureFill, PictureMode};
// `rotWithShape="0"` draws the tiles on the slide's axes, in device pixels: they were sized in
// pixels, so zooming (or a group's child scale) changed their size on the slide. PowerPoint's
// export of the same shapes shows 60x30 pt tiles at every zoom.
let draw = |scale: f64, group: f64| {
let mut p = Presentation::default();
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
let fill = Fill::Picture(PictureFill { media, mode, upright: true, ..Default::default() });
let shape = Shape {
id: ShapeId(22),
xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(0.0, 0.0, 240.0 / group, 120.0 / group) }),
fill: Some(fill),
line: no_line(),
..Default::default()
};
let shape = if group == 1.0 {
Shape { xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(100.0, 100.0, 240.0, 120.0) }), ..shape }
} else {
Shape {
id: ShapeId(21),
xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 120.0)),
kind: ShapeKind::Group { children: vec![shape], child: Xfrm::new(0.0, 0.0, 240.0 / group, 120.0 / group) },
..Default::default()
}
};
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![shape];
render_slide(&p, 0, &RenderOpts { scale, ..Default::default() })
};
for group in [1.0, 0.25] {
let (one, two) = (draw(1.0, group), draw(2.0, group));
let (mut same, mut total) = (0, 0);
for y in (120..220).step_by(7) {
for x in (120..320).step_by(9) {
let (a, b) = (one.pixel(x, y), two.pixel(2 * x, 2 * y));
if close(a, WHITE, 5) && close(b, WHITE, 5) {
continue; // outside the turned rectangle
}
total += 1;
same += usize::from(close(a, Rgba::rgb(b[0], b[1], b[2]), 40));
}
}
assert!(total > 40 && same * 10 >= total * 9, "group scale {group}: {same} of {total} samples agree between zoom 1 and 2");
}
}
#[test] #[test]
fn a_tiles_first_copy_sits_on_the_point_its_alignment_names() { fn a_tiles_first_copy_sits_on_the_point_its_alignment_names() {
let b = Rect::new(0.0, 0.0, 100.0, 50.0); let b = Rect::new(0.0, 0.0, 100.0, 50.0);
+18 -6
View File
@@ -93,12 +93,24 @@ impl BoxGeom {
} else { } else {
text_rect text_rect
}; };
let inner = Rect::new( // Insets wider than the shape scale down proportionally to fit inside it (#1632).
r.x0 + bp.inset_l.unwrap_or(7.2), let (mut ins_l, mut ins_r) = (bp.inset_l.unwrap_or(7.2), bp.inset_r.unwrap_or(7.2));
r.y0 + bp.inset_t.unwrap_or(3.6), let (mut ins_t, mut ins_b) = (bp.inset_t.unwrap_or(3.6), bp.inset_b.unwrap_or(3.6));
(r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)), let w = r.width().max(0.0);
(r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)), let total_x = ins_l + ins_r;
); if ins_l >= 0.0 && ins_r >= 0.0 && total_x > w && total_x > 0.0 {
let s = w / total_x;
ins_l *= s;
ins_r *= s;
}
let h = r.height().max(0.0);
let total_y = ins_t + ins_b;
if ins_t >= 0.0 && ins_b >= 0.0 && total_y > h && total_y > 0.0 {
let s = h / total_y;
ins_t *= s;
ins_b *= s;
}
let inner = Rect::new(r.x0 + ins_l, r.y0 + ins_t, (r.x1 - ins_r).max(r.x0 + ins_l), (r.y1 - ins_b).max(r.y0 + ins_t));
// Text that follows a WordArt arch or circle is set on one line per paragraph. // Text that follows a WordArt arch or circle is set on one line per paragraph.
let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow); let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow);
let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true)); let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
+2 -2
View File
@@ -76,7 +76,7 @@ pub(crate) fn shape_para(
if let Some(c) = &props.bullet_color { if let Some(c) = &props.bullet_color {
rp.fill = Some(Fill::solid(c.clone())); rp.fill = Some(Fill::solid(c.clone()));
} }
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0)); rp.size = Some(props.bullet_size_points.unwrap_or(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0)));
rp.underline = None; rp.underline = None;
rp.strike = None; rp.strike = None;
rp.highlight = None; rp.highlight = None;
@@ -105,7 +105,7 @@ pub(crate) fn shape_para(
if let Some(c) = &props.bullet_color { if let Some(c) = &props.bullet_color {
rp.fill = Some(Fill::solid(c.clone())); rp.fill = Some(Fill::solid(c.clone()));
} }
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0)); rp.size = Some(props.bullet_size_points.unwrap_or(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0)));
rp.underline = None; rp.underline = None;
let mut st = make_style(ctx, &rp, scale, opts.prompt_color); let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1))); let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
+36
View File
@@ -1946,3 +1946,39 @@ fn a_paragraph_with_its_own_start_at_restarts_the_count_there() {
// A later paragraph with a new start begins a fresh run. // A later paragraph with a new start begins a fresh run.
check(&[1, 1, 7, 7], &[1, 2, 7, 8]); check(&[1, 1, 7, 7], &[1, 2, 7, 8]);
} }
/// #1632: when insets are wider than the shape, they scale down proportionally so the text
/// rectangle stays inside the shape bounds and unwrapped text does not project outside it.
#[test]
fn insets_wider_than_the_shape_scale_down_within_the_shape_bounds() {
let (p, mut sh) = setup("Collaboration Hub", 14.17, 14.17);
sh.ph = None;
let bp = &mut sh.text.as_mut().unwrap().body;
bp.wrap = Some(false);
bp.inset_l = Some(127.5);
bp.inset_r = Some(11.33);
bp.inset_t = Some(11.33);
bp.inset_b = Some(11.33);
sh.text.as_mut().unwrap().paragraphs[0].props = deckcraft_model::text::ParaProps { align: Some(Align::Center), ..Default::default() };
let l = lay(&p, &sh, 14.17, 14.17);
assert_eq!(l.lines.len(), 1);
let line = &l.lines[0];
let mid = (line.caret_x[0] + *line.caret_x.last().unwrap()) / 2.0;
// The text center must remain inside the 14.17 pt wide shape, not pushed ~127.5 pt to the right.
assert!((0.0..=14.17).contains(&mid), "line center {mid} should be within [0, 14.17]");
}
/// #1632: signed insets (e.g. negative left inset with positive right inset) are preserved when
/// their sum fits within the shape bounds.
#[test]
fn signed_insets_are_preserved_when_they_fit() {
let (p, mut sh) = setup("Text", 100.0, 50.0);
sh.ph = None;
let bp = &mut sh.text.as_mut().unwrap().body;
bp.inset_l = Some(-10.0);
bp.inset_r = Some(10.0);
sh.text.as_mut().unwrap().paragraphs[0].props = deckcraft_model::text::ParaProps { align: Some(Align::Left), ..Default::default() };
let l = lay(&p, &sh, 100.0, 50.0);
assert_eq!(l.lines.len(), 1);
assert_eq!(l.inner.x0, -10.0);
}
+13 -9
View File
@@ -2,7 +2,7 @@
//! bar until Esc or the back arrow returns to the presentation. With nothing open it is the start //! bar until Esc or the back arrow returns to the presentation. With nothing open it is the start
//! screen: PowerPoint's start screen and File › Home are the same page. //! screen: PowerPoint's start screen and File › Home are the same page.
//! //!
//! A side menu on the left picks a page (Home, New, Open, Info, Save As, Print, Export, Account); //! A side menu on the left picks a page (Home, New, Open, Info, Save As, Print, Export, About);
//! Save, Close and Options act at once. Every action runs an existing command through //! Save, Close and Options act at once. Every action runs an existing command through
//! [`SlideApp::run`] or one of the app's file helpers ([`SlideApp::export_as`], //! [`SlideApp::run`] or one of the app's file helpers ([`SlideApp::export_as`],
//! [`SlideApp::print_pdf`], [`SlideApp::save_as_dialog`]); the state here is UI-only and never //! [`SlideApp::print_pdf`], [`SlideApp::save_as_dialog`]); the state here is UI-only and never
@@ -24,7 +24,7 @@ mod print;
pub use files::split_path; pub use files::split_path;
use files::{home, new_page, open_page}; use files::{home, new_page, open_page};
use info::{PROPERTIES, account, commit_props, info, save_as}; use info::{PROPERTIES, about, commit_props, info, save_as};
pub use info::{SAVE_TYPES, format_bytes}; pub use info::{SAVE_TYPES, format_bytes};
use print::{export, print}; use print::{export, print};
pub use print::{print_page_size, print_params, print_slides}; pub use print::{print_page_size, print_params, print_slides};
@@ -47,11 +47,11 @@ pub enum Page {
SaveAs, SaveAs,
Print, Print,
Export, Export,
Account, About,
} }
impl Page { impl Page {
pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Account]; pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::About];
/// The id `app.backstage {page}` takes. /// The id `app.backstage {page}` takes.
pub fn id(self) -> &'static str { pub fn id(self) -> &'static str {
@@ -63,12 +63,16 @@ impl Page {
Page::SaveAs => "saveAs", Page::SaveAs => "saveAs",
Page::Print => "print", Page::Print => "print",
Page::Export => "export", Page::Export => "export",
Page::Account => "account", Page::About => "about",
} }
} }
pub fn parse(id: &str) -> Option<Page> { pub fn parse(id: &str) -> Option<Page> {
let id = id.trim(); let id = id.trim();
// Keep existing control-channel callers working after the visible page rename.
if id.eq_ignore_ascii_case("account") {
return Some(Page::About);
}
Page::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id)) Page::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id))
} }
@@ -82,7 +86,7 @@ impl Page {
Page::SaveAs => "Save As", Page::SaveAs => "Save As",
Page::Print => "Print", Page::Print => "Print",
Page::Export => "Export", Page::Export => "Export",
Page::Account => "Account", Page::About => "About",
} }
} }
@@ -95,7 +99,7 @@ impl Page {
Page::SaveAs => Icon::SaveAs, Page::SaveAs => Icon::SaveAs,
Page::Print => Icon::Print, Page::Print => Icon::Print,
Page::Export => Icon::Export, Page::Export => Icon::Export,
Page::Account => Icon::Account, Page::About => Icon::Help,
} }
} }
@@ -140,7 +144,7 @@ pub fn entries(has_doc: bool) -> (Vec<Nav>, Vec<Nav>) {
if has_doc { if has_doc {
top.extend([Nav::Page(Page::Info), Nav::Save, Nav::Page(Page::SaveAs), Nav::Page(Page::Print), Nav::Page(Page::Export), Nav::Close]); top.extend([Nav::Page(Page::Info), Nav::Save, Nav::Page(Page::SaveAs), Nav::Page(Page::Print), Nav::Page(Page::Export), Nav::Close]);
} }
(top, vec![Nav::Page(Page::Account), Nav::Options]) (top, vec![Nav::Page(Page::About), Nav::Options])
} }
/// Print page: which slides. /// Print page: which slides.
@@ -412,7 +416,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
Page::SaveAs => save_as(app, ui, &mut bs, &mut out), Page::SaveAs => save_as(app, ui, &mut bs, &mut out),
Page::Print => print(app, ui, &mut bs), Page::Print => print(app, ui, &mut bs),
Page::Export => export(app, ui, &mut bs), Page::Export => export(app, ui, &mut bs),
Page::Account => account(app, ui), Page::About => about(app, ui),
} }
}); });
}); });
+23 -31
View File
@@ -189,11 +189,30 @@ pub(super) fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out:
} }
} }
/// Account: the interface's appearance and what DeckCraft is. DeckCraft has no accounts, so /// Product information, project links, credits and appearance preferences.
/// there is nothing to sign in to. pub(super) fn about(app: &mut SlideApp, ui: &mut Ui) {
pub(super) fn account(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
ui.horizontal_top(|ui| { // Side by side needs 480 + 40 + 340 points; stack the columns when the page is narrower.
let layout = if ui.available_width() >= 860.0 { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) };
ui.with_layout(layout, |ui| {
ui.vertical(|ui| {
ui.set_width(480.0);
heading(ui, "Product Information");
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover());
crate::dialogs::paint_logo(ui.painter(), r);
ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text));
});
dim(ui, &crate::dialogs::version_line());
dim(ui, "Open source under MIT OR Apache-2.0. Not affiliated with Microsoft.");
ui.add_space(14.0);
if task(ui, Icon::Help, "Credits", "Credits", "The people and models who built DeckCraft.", true) {
let _ = app.run("app.about", json!({}));
}
ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD);
ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB);
});
ui.add_space(40.0);
ui.vertical(|ui| { ui.vertical(|ui| {
ui.set_width(340.0); ui.set_width(340.0);
heading(ui, "Appearance"); heading(ui, "Appearance");
@@ -210,33 +229,6 @@ pub(super) fn account(app: &mut SlideApp, ui: &mut Ui) {
}) })
.response .response
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Appearance")); .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Appearance"));
ui.add_space(16.0);
heading(ui, "User Information");
dim(ui, "DeckCraft has no accounts: there is nothing to sign in to, and your presentations stay on your device.");
});
ui.add_space(40.0);
ui.vertical(|ui| {
heading(ui, "Product Information");
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover());
crate::dialogs::paint_logo(ui.painter(), r);
ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text));
});
dim(ui, &crate::dialogs::version_line());
dim(ui, "Open source under MIT OR Apache-2.0. Not affiliated with Microsoft.");
ui.add_space(14.0);
if task(
ui,
Icon::Help,
"About DeckCraft",
"About DeckCraft",
"The version, the people and models who built it, and where to find us.",
true,
) {
let _ = app.run("app.about", json!({}));
}
ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD);
ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB);
}); });
}); });
} }
+41 -2
View File
@@ -85,11 +85,13 @@ fn page_ids_round_trip_and_unknown_ids_are_refused() {
assert_eq!(Page::parse(p.id()), Some(p)); assert_eq!(Page::parse(p.id()), Some(p));
} }
assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs)); assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs));
assert_eq!(Page::parse("account"), Some(Page::About));
assert_eq!(Page::parse("about"), Some(Page::About));
assert_eq!(Page::parse("share"), None); assert_eq!(Page::parse("share"), None);
assert_eq!(Page::parse(""), None); assert_eq!(Page::parse(""), None);
let (top, bottom) = entries(false); let (top, bottom) = entries(false);
assert_eq!(top, vec![Nav::Page(Page::Home), Nav::Page(Page::New), Nav::Page(Page::Open)], "the start screen has no presentation pages"); assert_eq!(top, vec![Nav::Page(Page::Home), Nav::Page(Page::New), Nav::Page(Page::Open)], "the start screen has no presentation pages");
assert_eq!(bottom, vec![Nav::Page(Page::Account), Nav::Options]); assert_eq!(bottom, vec![Nav::Page(Page::About), Nav::Options]);
assert_eq!(entries(true).0.len(), 9); assert_eq!(entries(true).0.len(), 9);
assert_eq!(split_path("/home/me/Talk.pptx"), ("Talk.pptx".into(), "/home/me".into())); assert_eq!(split_path("/home/me/Talk.pptx"), ("Talk.pptx".into(), "/home/me".into()));
assert_eq!(format_bytes(512), "512 bytes"); assert_eq!(format_bytes(512), "512 bytes");
@@ -166,7 +168,7 @@ fn every_page_renders() {
(Page::SaveAs, "Not saved yet"), (Page::SaveAs, "Not saved yet"),
(Page::Print, "Print Hidden Slides"), (Page::Print, "Print Hidden Slides"),
(Page::Export, "Create PDF Document"), (Page::Export, "Create PDF Document"),
(Page::Account, "About DeckCraft"), (Page::About, "Credits"),
]; ];
for (p, marker) in checks { for (p, marker) in checks {
h.state_mut().run("app.backstage", json!({"page": p.id()})).unwrap(); h.state_mut().run("app.backstage", json!({"page": p.id()})).unwrap();
@@ -704,3 +706,40 @@ fn options_in_backstage_opens_preferences_and_cancel_closes_it() {
assert!(h.state().dialog.is_none(), "Cancel closes Preferences dialog"); assert!(h.state().dialog.is_none(), "Cancel closes Preferences dialog");
assert!(h.state().backstage_open(), "Backstage remains open"); assert!(h.state().backstage_open(), "Backstage remains open");
} }
/// About replaces the account entry and still opens the credits dialog.
#[test]
fn about_has_product_links_and_credits_without_account_controls() {
let mut h = harness(new_app());
h.get_by_label("File").click();
h.run_steps(3);
assert!(h.query_by_label("Account").is_none());
click_nav(&h, "About");
h.run_steps(3);
assert_eq!(page(&h), Some(Page::About));
assert!(h.query_by_label("User Information").is_none());
assert!(h.query_by_label("Source on GitHub").is_some());
assert!(h.query_by_label("Join our community on Discord").is_some());
assert!(h.query_all_by_label("Appearance").next().is_some());
click_on_page(&h, "Credits");
h.run_steps(3);
assert!(h.state().dialog.is_some());
assert!(h.query_by_label("Contributors").is_some());
assert!(h.query_by_label("Models").is_some());
}
/// At the 900-point minimum window width the About columns stack and Appearance stays on the page.
#[test]
fn about_appearance_stays_on_the_page_at_minimum_width() {
let mut h = Harness::builder().with_size(egui::vec2(900.0, 900.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
new_app(),
);
h.state_mut().run("app.backstage", json!({"page": "about"})).unwrap();
h.run_steps(3);
let r = h.query_all_by_role_and_label(Role::ComboBox, "Appearance").next().expect("Appearance selector").rect();
assert!(r.right() <= 900.0, "Appearance selector ends at {}, inside the window", r.right());
}
+2 -1
View File
@@ -26,6 +26,7 @@ pub mod icons;
pub mod media; pub mod media;
pub mod menus; pub mod menus;
pub mod opening; pub mod opening;
pub mod presenter;
pub mod ribbon; pub mod ribbon;
pub mod settings; pub mod settings;
pub mod show; pub mod show;
@@ -128,7 +129,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("show.end", "End Show", None, "{}"), ("show.end", "End Show", None, "{}"),
("animation.preview", "Preview Animations", None, "{on?: bool, from?: effect index}"), ("animation.preview", "Preview Animations", None, "{on?: bool, from?: effect index}"),
("app.home", "Home", None, "{on?}"), ("app.home", "Home", None, "{on?}"),
("app.backstage", "File", Some("Alt+F"), "{page?: home|new|open|info|saveAs|print|export|account, on?: bool}"), ("app.backstage", "File", Some("Alt+F"), "{page?: home|new|open|info|saveAs|print|export|about, on?: bool}"),
("app.closePresentation", "Close", Some("Cmd+W"), "{}"), ("app.closePresentation", "Close", Some("Cmd+W"), "{}"),
( (
"app.print", "app.print",
+26 -21
View File
@@ -130,29 +130,34 @@ fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c:
for r in &c.replies { for r in &c.replies {
ui.label(egui::RichText::new(format!("↳ {}: {}", r.author, r.text)).color(t.text_dim)); ui.label(egui::RichText::new(format!("↳ {}: {}", r.author, r.text)).color(t.text_dim));
} }
// The buttons are laid out first, from the right, so the reply field takes exactly the
// width they leave. A fixed allowance overflowed the card and widened the pane every
// frame until it stuck at its widest (#1431).
ui.horizontal(|ui| { ui.horizontal(|ui| {
let id = ui.scope_id().with(("reply", slide, index)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default()); if ui.small_button("🗑").clicked() {
let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width() - 70.0)); let _ = app.run("slide.go", json!({"index": slide}));
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() { let _ = app.run("comment.delete", json!({"index": index}));
focus_comment(app, slide, index); match app.ui.comment_focus {
run(app, "comment.reply", json!({"index": index, "text": txt})); Some((fs, fi)) if fs == slide && fi == index => app.ui.comment_focus = None,
txt.clear(); Some((fs, fi)) if fs == slide && fi > index => app.ui.comment_focus = Some((fs, fi - 1)),
} _ => {}
ui.data_mut(|d| d.insert_temp(id, txt)); }
if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() {
focus_comment(app, slide, index);
run(app, "comment.resolve", json!({"index": index, "resolved": !c.resolved}));
}
if ui.small_button("🗑").clicked() {
let _ = app.run("slide.go", json!({"index": slide}));
let _ = app.run("comment.delete", json!({"index": index}));
match app.ui.comment_focus {
Some((fs, fi)) if fs == slide && fi == index => app.ui.comment_focus = None,
Some((fs, fi)) if fs == slide && fi > index => app.ui.comment_focus = Some((fs, fi - 1)),
_ => {}
} }
} if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() {
focus_comment(app, slide, index);
run(app, "comment.resolve", json!({"index": index, "resolved": !c.resolved}));
}
let id = ui.scope_id().with(("reply", slide, index));
let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default());
let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width()));
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() {
focus_comment(app, slide, index);
run(app, "comment.reply", json!({"index": index, "text": txt}));
txt.clear();
}
ui.data_mut(|d| d.insert_temp(id, txt));
});
}); });
}); });
if focused { if focused {
+450
View File
@@ -0,0 +1,450 @@
//! Bounded presenter-window messages. The audience owns the show; the remote window
//! receives previews and sends input, never a second independently advancing show.
use serde::{Deserialize, Serialize};
pub const MAX_PREVIEW_SIDE: usize = 640;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Preview {
pub width: usize,
pub height: usize,
pub rgba: Vec<u8>,
}
impl Preview {
pub fn from_image(image: &egui::ColorImage) -> Option<Self> {
let [w, h] = image.size;
if w == 0 || h == 0 || w.checked_mul(h)? != image.pixels.len() {
return None;
}
let scale = (MAX_PREVIEW_SIDE as f64 / w.max(h) as f64).min(1.0);
let width = ((w as f64 * scale) as usize).max(1);
let height = ((h as f64 * scale) as usize).max(1);
let mut rgba = Vec::with_capacity(width * height * 4);
for y in 0..height {
for x in 0..width {
let pixel = image.pixels.get((y * h / height) * w + x * w / width)?;
rgba.extend_from_slice(&pixel.to_srgba_unmultiplied());
}
}
Some(Self { width, height, rgba })
}
pub fn image(&self) -> Option<egui::ColorImage> {
if self.width == 0
|| self.height == 0
|| self.width > MAX_PREVIEW_SIDE
|| self.height > MAX_PREVIEW_SIDE
|| self.width.checked_mul(self.height)?.checked_mul(4)? != self.rgba.len()
{
return None;
}
Some(egui::ColorImage::from_rgba_unmultiplied([self.width, self.height], &self.rgba))
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Snapshot {
pub slide: usize,
pub total: usize,
pub ended: bool,
pub notes: String,
pub elapsed: String,
pub paused: bool,
pub notes_px: f32,
pub blank: Option<String>,
pub grid: bool,
pub zoom_armed: bool,
pub current: Option<Preview>,
pub next: Option<Preview>,
}
/// Images acknowledged by a successful transport send, never merely attempted images.
#[derive(Default)]
pub struct DeltaCache {
current: Option<Preview>,
next: Option<Preview>,
token: String,
}
impl DeltaCache {
pub fn send<E>(
&mut self,
mut snapshot: Snapshot,
token: &str,
refresh: bool,
send: impl FnOnce(Snapshot, bool, bool) -> Result<(), E>,
) -> Result<(), E> {
let fresh = refresh || self.token != token;
let current_changed = fresh || self.current != snapshot.current;
let next_changed = fresh || self.next != snapshot.next;
let current = snapshot.current.clone();
let next = snapshot.next.clone();
if !current_changed {
snapshot.current = None;
}
if !next_changed {
snapshot.next = None;
}
send(snapshot, current_changed, next_changed)?;
self.current = current;
self.next = next;
self.token = token.to_owned();
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Command {
Key { name: String, command: bool, shift: bool, alt: bool, ctrl: bool },
Zoom { x: f32, y: f32 },
End,
}
/// Implemented by the browser host. Native hosts retain the existing egui viewport.
pub trait Host {
fn open(&mut self) -> Result<(), String>;
fn is_open(&self) -> bool;
fn commands(&mut self) -> Vec<Command>;
fn update(&mut self, snapshot: Snapshot);
fn close(&mut self);
}
/// The popup's independent egui renderer. It never advances a local show clock.
#[derive(Default)]
pub struct View {
fonts: crate::uifonts::UiFonts,
styled: bool,
current: Option<egui::TextureHandle>,
next: Option<egui::TextureHandle>,
previous_current: Option<Preview>,
previous_next: Option<Preview>,
}
impl View {
/// Install the same interface and dynamic fallback fonts as SlideApp before layout.
/// Font definitions take effect on the next pass; never forward input on a discarded pass.
pub fn prepare(&mut self, ctx: &egui::Context, notes: &str) -> bool {
if !self.styled {
crate::theme::install_fonts_with(ctx, &self.fonts.faces);
crate::theme::apply(ctx, &crate::theme::Tokens::dark());
self.styled = true;
ctx.request_discard("install presenter interface fonts before layout");
return false;
}
if ctx.will_discard() {
return false;
}
self.fonts.note(ctx, notes);
if self.fonts.apply(ctx) {
ctx.request_discard("install presenter note fallback fonts before layout");
return false;
}
true
}
pub fn ui(&mut self, ui: &mut egui::Ui, snapshot: &Snapshot) -> Vec<Command> {
if !self.prepare(ui.ctx(), &snapshot.notes) {
return Vec::new();
}
let mut commands = Vec::new();
let keys = ui.input(|i| {
i.events
.iter()
.filter_map(|event| if let egui::Event::Key { key, pressed: true, modifiers, .. } = event { Some((*key, *modifiers)) } else { None })
.collect::<Vec<_>>()
});
for (key, modifiers) in keys {
// Consume once before widgets see Space/Enter, avoiding a second button activation.
ui.input_mut(|i| i.consume_key(modifiers, key));
commands.push(Command::Key {
name: key.name().into(),
command: modifiers.command,
shift: modifiers.shift,
alt: modifiers.alt,
ctrl: modifiers.ctrl,
});
}
fn update(
ctx: &egui::Context,
name: &str,
previous: &mut Option<Preview>,
texture: &mut Option<egui::TextureHandle>,
image: &Option<Preview>,
) {
if previous == image {
return;
}
*previous = image.clone();
*texture = image.as_ref().and_then(Preview::image).map(|image| ctx.load_texture(name, image, egui::TextureOptions::LINEAR));
}
update(ui.ctx(), "remote-current", &mut self.previous_current, &mut self.current, &snapshot.current);
update(ui.ctx(), "remote-next", &mut self.previous_next, &mut self.next, &snapshot.next);
let key = |name: &str| Command::Key { name: name.into(), command: false, shift: false, alt: false, ctrl: false };
ui.horizontal_wrapped(|ui| {
ui.heading(&snapshot.elapsed);
ui.label(format!("Slide {} of {}", snapshot.slide.saturating_add(1), snapshot.total));
for (label, name) in [("◀ Previous", "ArrowLeft"), ("Next ▶", "ArrowRight")] {
if ui.button(label).clicked() {
commands.push(key(name));
}
}
if ui.button("End Show").clicked() {
commands.push(Command::End);
}
});
ui.horizontal_wrapped(|ui| {
for (label, name) in [
(if snapshot.paused { "Resume" } else { "Pause" }, "T"),
("Reset", "R"),
("Black", "B"),
("White", "W"),
("All slides", "G"),
("Zoom", "Z"),
] {
if ui.button(label).clicked() {
commands.push(key(name));
}
}
});
ui.separator();
ui.columns(2, |columns| {
let left = &mut columns[0];
if snapshot.ended {
left.heading("End of slide show");
} else if let Some(texture) = &self.current {
let response = left.add(
egui::Image::new(texture)
.fit_to_exact_size(fit_preview(texture.size_vec2(), left.available_width(), left.available_height() * 0.8))
.sense(egui::Sense::click()),
);
if let Some(blank) = &snapshot.blank {
let color = if blank == "white" { egui::Color32::WHITE } else { egui::Color32::BLACK };
left.painter().rect_filled(response.rect, 0.0, color);
}
if snapshot.zoom_armed
&& response.clicked()
&& let Some(point) = response.interact_pointer_pos()
{
commands.push(Command::Zoom {
x: (point.x - response.rect.left()) / response.rect.width().max(1.0),
y: (point.y - response.rect.top()) / response.rect.height().max(1.0),
});
}
}
if snapshot.zoom_armed {
left.label("Click the preview to zoom.");
}
if snapshot.grid {
left.label("Choose a slide in the audience window, or press G to return.");
}
let right = &mut columns[1];
right.label("Next");
if let Some(texture) = &self.next {
right.add(egui::Image::new(texture).fit_to_exact_size(fit_preview(
texture.size_vec2(),
right.available_width(),
right.available_height() * 0.45,
)));
} else {
right.label("End of slide show");
}
right.horizontal(|ui| {
ui.label("Notes");
if ui.button("A−").clicked() {
commands.push(key(egui::Key::OpenBracket.name()));
}
if ui.button("A+").clicked() {
commands.push(key(egui::Key::CloseBracket.name()));
}
});
egui::ScrollArea::vertical().show(right, |ui| {
ui.label(
egui::RichText::new(if snapshot.notes.is_empty() { "No notes." } else { &snapshot.notes })
.size(snapshot.notes_px.clamp(10.0, 72.0)),
);
});
});
commands
}
}
fn fit_preview(size: egui::Vec2, width: f32, height: f32) -> egui::Vec2 {
size * (width.max(1.0) / size.x.max(1.0)).min(height.max(1.0) / size.y.max(1.0))
}
#[cfg(test)]
mod tests {
use super::*;
fn snapshot() -> Snapshot {
Snapshot {
slide: 0,
total: 2,
ended: false,
notes: String::new(),
elapsed: "00:00:00".into(),
paused: false,
notes_px: 18.0,
blank: None,
grid: false,
zoom_armed: false,
current: Some(Preview { width: 1, height: 1, rgba: vec![255; 4] }),
next: Some(Preview { width: 1, height: 1, rgba: vec![0; 4] }),
}
}
#[test]
fn failed_transport_retries_images_and_only_success_deduplicates() {
let mut cache = DeltaCache::default();
let first = snapshot();
let failed = cache.send(first.clone(), "session", false, |delta, current, next| {
assert!(current && next);
assert_eq!(delta.current, first.current);
assert_eq!(delta.next, first.next);
Err("serialization or postMessage failed")
});
assert!(failed.is_err());
let sent = cache.send(first.clone(), "session", false, |delta, current, next| {
assert!(current && next, "a failed initial send must not suppress either image");
assert_eq!(delta.current, first.current);
assert_eq!(delta.next, first.next);
Ok::<_, ()>(())
});
assert!(sent.is_ok());
let sent = cache.send(first.clone(), "session", false, |delta, current, next| {
assert!(!current && !next);
assert!(delta.current.is_none() && delta.next.is_none());
Ok::<_, ()>(())
});
assert!(sent.is_ok());
let mut changed = first.clone();
changed.current.as_mut().unwrap().rgba = vec![42; 4];
let failed = cache.send(changed.clone(), "session", false, |_, current, next| {
assert!(current && !next);
Err(())
});
assert!(failed.is_err());
let sent = cache.send(changed.clone(), "session", false, |delta, current, next| {
assert!(current && !next, "unchanged retry after a failed changed-image send");
assert_eq!(delta.current, changed.current);
Ok::<_, ()>(())
});
assert!(sent.is_ok());
}
#[test]
fn transport_refreshes_new_sessions_reloads_and_clears_previews() {
let mut cache = DeltaCache::default();
for (token, refresh) in [("first", false), ("second", false), ("second", true)] {
let sent = cache.send(snapshot(), token, refresh, |delta, current, next| {
assert!(current && next);
assert!(delta.current.is_some() && delta.next.is_some());
Ok::<_, ()>(())
});
assert!(sent.is_ok());
}
let failed = cache.send(snapshot(), "second", true, |_, current, next| {
assert!(current && next);
Err(())
});
// BrowserHost retains refresh_images after failure, even when the token is unchanged.
let refresh_images = failed.is_err();
let sent = cache.send(snapshot(), "second", refresh_images, |_, current, next| {
assert!(current && next, "reload refresh must survive a failed send");
Ok::<_, ()>(())
});
assert!(sent.is_ok());
let mut cleared = snapshot();
cleared.current = None;
cleared.next = None;
let failed = cache.send(cleared.clone(), "second", false, |_, current, next| {
assert!(current && next);
Err(())
});
assert!(failed.is_err());
let sent = cache.send(cleared.clone(), "second", false, |delta, current, next| {
assert!(current && next, "clearing images is retried too");
assert!(delta.current.is_none() && delta.next.is_none());
Ok::<_, ()>(())
});
assert!(sent.is_ok());
let sent = cache.send(cleared, "second", false, |_, current, next| {
assert!(!current && !next);
Ok::<_, ()>(())
});
assert!(sent.is_ok());
}
#[test]
fn popup_installs_interface_fonts_before_non_latin_note_layout() {
let ctx = egui::Context::default();
let mut view = View::default();
let mut data = snapshot();
let arabic = "\u{645}\u{631}\u{62d}\u{628}\u{627}";
let japanese = "\u{65e5}\u{672c}\u{8a9e}";
let korean = "\u{d55c}\u{ae00}";
data.notes = format!("{arabic} {japanese} {korean}");
let mut installing = 0;
let mut layouts = 0;
for _ in 0..4 {
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
if !view.prepare(ui.ctx(), &data.notes) {
installing += 1;
assert!(view.ui(ui, &data).is_empty(), "no commands during a discarded font-install pass");
return;
}
assert!(!ui.ctx().will_discard());
layouts += 1;
for (family, text) in [("Noto Sans Arabic", arabic), ("BIZ UDPGothic", japanese), ("NanumGothic", korean)] {
if deckcraft_fonts::CRAFT_FONTS.iter().any(|face| face.family == family) {
ui.ctx().fonts_mut(|fonts| {
for c in text.chars() {
assert!(fonts.has_glyph(&egui::FontId::proportional(18.0), c), "missing bundled {family} glyph {c:?}");
}
});
}
}
let _ = view.ui(ui, &data);
});
output.textures_delta.clear();
}
assert!(installing > 0 && layouts > 0, "font installation must precede an actual notes layout");
}
#[test]
fn popup_input_is_forwarded_once_without_advancing_its_own_state() {
use egui_kittest::kittest::Queryable;
let snapshot = Snapshot {
slide: 0,
total: 3,
ended: false,
notes: "Synthetic notes".into(),
elapsed: "00:00:00".into(),
paused: false,
notes_px: 18.0,
blank: None,
grid: false,
zoom_armed: false,
current: None,
next: None,
};
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|ui, (view, snapshot, sent): &mut (View, Snapshot, Vec<Command>)| {
sent.extend(view.ui(ui, snapshot));
},
(View::default(), snapshot, Vec::new()),
);
h.run_steps(2);
h.get_by_label("Next ▶").click();
h.run_steps(2);
assert_eq!(h.state().2.len(), 1);
h.key_press(egui::Key::Space);
h.run_steps(2);
assert_eq!(h.state().2.len(), 2, "Space must not also activate the focused Next button");
assert_eq!(h.state().0.previous_current, None);
assert_eq!(h.state().1.slide, 0, "only an audience snapshot changes the displayed position");
assert!(matches!(h.state().2.last(), Some(Command::Key { name, .. }) if name == egui::Key::Space.name()));
}
#[test]
fn previews_are_bounded_and_bad_dimensions_are_rejected() {
let image = egui::ColorImage::filled([1280, 720], egui::Color32::RED);
let p = Preview::from_image(&image).unwrap();
assert_eq!((p.width, p.height), (640, 360));
assert_eq!(p.image().unwrap().pixels[0], egui::Color32::RED);
for (width, height, rgba) in [(usize::MAX, 1, vec![]), (0, 1, vec![]), (1, 1, vec![0; 3]), (641, 1, vec![0; 2564])] {
assert!(Preview { width, height, rgba }.image().is_none());
}
}
}
+108 -2
View File
@@ -1,4 +1,3 @@
use super::transitions::option_label;
use super::{ use super::{
Align2, AnimClass, CornerRadius, Icon, Rect, Sense, SlideApp, Tokens, Ui, big, big_button, big_button_ex, dropdown, enabled, group, icons, json, Align2, AnimClass, CornerRadius, Icon, Rect, Sense, SlideApp, Tokens, Ui, big, big_button, big_button_ex, dropdown, enabled, group, icons, json,
pos2, rows, run, theme, vec2, widgets, pos2, rows, run, theme, vec2, widgets,
@@ -81,12 +80,13 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) {
} }
}); });
let effect_rows = cur.as_ref().map(|c| animation_effect_rows(&c.effect, c.class, Some(c.option.as_str()))).unwrap_or_default(); let effect_rows = cur.as_ref().map(|c| animation_effect_rows(&c.effect, c.class, Some(c.option.as_str()))).unwrap_or_default();
let effect = cur.as_ref().map(|c| c.effect.as_str()).unwrap_or_default();
let build = cur.as_ref().map(|c| text_build_id(c.text_build)); let build = cur.as_ref().map(|c| text_build_id(c.text_build));
let build_rows = marked_options(&["asOne", "byParagraph"], build); let build_rows = marked_options(&["asOne", "byParagraph"], build);
let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty()); let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty());
egui::Popup::menu(&r).show(|ui| { egui::Popup::menu(&r).show(|ui| {
for &(o, on) in &effect_rows { for &(o, on) in &effect_rows {
if ui.selectable_label(on, option_label(o)).clicked() { if ui.selectable_label(on, animation_option_label(effect, o)).clicked() {
run(app, "animation.options", json!({"option": o})); run(app, "animation.options", json!({"option": o}));
ui.close(); ui.close();
} }
@@ -216,6 +216,70 @@ pub(super) fn animation_effect_rows(effect: &str, class: AnimClass, current: Opt
marked_options(opts, current) marked_options(opts, current)
} }
fn animation_option_label(effect: &str, option: &str) -> String {
let direction = match option {
"b" => Some("From Bottom"),
"lb" => Some("From Bottom-Left"),
"l" => Some("From Left"),
"lt" => Some("From Top-Left"),
"t" => Some("From Top"),
"rt" => Some("From Top-Right"),
"r" => Some("From Right"),
"rb" => Some("From Bottom-Right"),
_ => None,
};
if matches!(effect, "fly" | "flyOut" | "wipe" | "wipeOut" | "peek" | "peekOut" | "crawl" | "crawlOut")
&& let Some(label) = direction
{
if matches!(effect, "flyOut" | "peekOut" | "crawlOut") {
return label.replacen("From", "To", 1);
}
return label.into();
}
match (effect, option) {
("float" | "floatOut", "u") => "Up".into(),
("float" | "floatOut", "d") => "Down".into(),
("split" | "splitOut", "horzIn") => "Horizontal In".into(),
("split" | "splitOut", "horzOut") => "Horizontal Out".into(),
("split" | "splitOut", "vertIn") => "Vertical In".into(),
("split" | "splitOut", "vertOut") => "Vertical Out".into(),
("shape" | "shapeOut", o) if o.ends_with("In") => format!("{} In", title_first(shape_option_name(o))),
("shape" | "shapeOut", o) if o.ends_with("Out") => format!("{} Out", title_first(shape_option_name(o))),
("wheel" | "wheelOut", "1") => "1 Spoke".into(),
("wheel" | "wheelOut", n) => format!("{n} Spokes"),
("box" | "boxOut" | "circle" | "diamond" | "diamondOut", "in") => format!("{} In", title_first(effect.trim_end_matches("Out"))),
("box" | "boxOut" | "circle" | "diamond" | "diamondOut", "out") => format!("{} Out", title_first(effect.trim_end_matches("Out"))),
("zoom" | "zoomOut", "in") => "Zoom In".into(),
("zoom" | "zoomOut", "out") => "Zoom Out".into(),
("strips" | "stripsOut", "lu") => "Left Up".into(),
("strips" | "stripsOut", "ru") => "Right Up".into(),
("strips" | "stripsOut", "ld") => "Left Down".into(),
("strips" | "stripsOut", "rd") => "Right Down".into(),
(_, "horz") => "Horizontal".into(),
(_, "vert") => "Vertical".into(),
(_, "across") => "Across".into(),
(_, "down") => "Down".into(),
(_, "up") => "Up".into(),
(_, "right") => "Right".into(),
(_, "left") => "Left".into(),
(_, "clockwise") => "Clockwise".into(),
(_, "counterClockwise") => "Counterclockwise".into(),
(_, "in") => "In".into(),
(_, "out") => "Out".into(),
(_, other) => super::transitions::option_label(other),
}
}
fn shape_option_name(option: &str) -> &str {
option.strip_suffix("In").or_else(|| option.strip_suffix("Out")).unwrap_or(option)
}
fn title_first(value: &str) -> String {
let mut chars = value.chars();
chars.next().map(|c| c.to_uppercase().collect::<String>() + chars.as_str()).unwrap_or_default()
}
pub(super) fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static str { pub(super) fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static str {
match build { match build {
deckcraft_model::anim::TextBuild::ByParagraph => "byParagraph", deckcraft_model::anim::TextBuild::ByParagraph => "byParagraph",
@@ -224,3 +288,45 @@ pub(super) fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static
deckcraft_model::anim::TextBuild::AsOne => "asOne", deckcraft_model::anim::TextBuild::AsOne => "asOne",
} }
} }
#[cfg(test)]
mod animation_option_label_tests {
use super::animation_option_label;
use deckcraft_model::anim::ANIMATIONS;
#[test]
fn labels_every_animation_gallery_option_without_changing_its_code() {
for &(effect, _, _, _, _, options) in ANIMATIONS {
for &option in options {
let label = animation_option_label(effect, option);
assert!(!label.is_empty(), "{effect}/{option}");
assert_ne!(label, option, "raw option shown for {effect}/{option}");
}
}
assert_eq!(animation_option_label("fly", "lb"), "From Bottom-Left");
assert_eq!(animation_option_label("flyOut", "rt"), "To Top-Right");
assert_eq!(animation_option_label("flyOut", "b"), "To Bottom");
assert_eq!(animation_option_label("peekOut", "l"), "To Left");
assert_eq!(animation_option_label("wipe", "b"), "From Bottom");
assert_eq!(animation_option_label("wheel", "1"), "1 Spoke");
assert_eq!(animation_option_label("split", "vertOut"), "Vertical Out");
assert_eq!(animation_option_label("shape", "circleIn"), "Circle In");
assert_eq!(animation_option_label("diamond", "out"), "Diamond Out");
assert_eq!(animation_option_label("diamondOut", "in"), "Diamond In");
assert_eq!(animation_option_label("diamondOut", "out"), "Diamond Out");
assert_eq!(animation_option_label("strips", "lu"), "Left Up");
assert_eq!(animation_option_label("strips", "ru"), "Right Up");
assert_eq!(animation_option_label("strips", "ld"), "Left Down");
assert_eq!(animation_option_label("strips", "rd"), "Right Down");
assert_eq!(animation_option_label("crawl", "b"), "From Bottom");
assert_eq!(animation_option_label("crawl", "l"), "From Left");
assert_eq!(animation_option_label("crawl", "t"), "From Top");
assert_eq!(animation_option_label("crawl", "r"), "From Right");
assert_eq!(animation_option_label("crawlOut", "l"), "To Left");
assert_eq!(animation_option_label("crawlOut", "r"), "To Right");
assert_eq!(animation_option_label("plus", "in"), "In");
assert_eq!(animation_option_label("plus", "out"), "Out");
assert_eq!(animation_option_label("changeFontSize", "increase"), "Increase");
assert_eq!(animation_option_label("changeFontSize", "decrease"), "Decrease");
}
}
+8 -5
View File
@@ -12,11 +12,14 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) {
let cur = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.transition.clone())); let cur = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.transition.clone()));
let kind = cur.as_ref().map(|t| t.kind.clone()).unwrap_or_else(|| "none".into()); let kind = cur.as_ref().map(|t| t.kind.clone()).unwrap_or_else(|| "none".into());
group(ui, |ui| { group(ui, |ui| {
if big_button(ui, Icon::Preview, "Preview", true).clicked() { let playing = app.show.as_ref().is_some_and(|s| s.preview_only);
let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); let (resp, _) = widgets::big_button_ex(ui, Icon::Preview, "Preview", true, false, playing);
app.start_show(from, true); if resp.on_hover_text("Play this slide's transition").clicked() {
if let Some(s) = app.show.as_mut() { let to = app.session.active().map(|d| d.selection.slide).unwrap_or(0);
s.preview_only = true; app.start_show(to, true);
let now = ui.input(|i| i.time);
if let (Some(s), Some(d)) = (app.show.as_mut(), app.session.active()) {
s.start_transition_preview(&d.doc, now);
} }
} }
}); });
+2
View File
@@ -36,6 +36,8 @@ pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<InboxEvent>>>;
/// Platform services injected by the host (desktop or web). /// Platform services injected by the host (desktop or web).
#[derive(Default)] #[derive(Default)]
pub struct Services { pub struct Services {
/// Browser-owned presenter popup; absent on native hosts.
pub presenter: Option<Box<dyn crate::presenter::Host>>,
/// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path. /// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path.
pub pick_open: Option<PickFn>, pub pick_open: Option<PickFn>,
/// Save dialog with a suggested name → path. /// Save dialog with a suggested name → path.
+29
View File
@@ -63,6 +63,10 @@ pub struct Show {
/// Preview renders started on another thread. /// Preview renders started on another thread.
#[cfg(test)] #[cfg(test)]
preview_spawns: usize, preview_spawns: usize,
remote_preview: Option<crate::presenter::Preview>,
remote_next: Option<(usize, crate::presenter::Preview)>,
remote_sent: f64,
remote_opened: bool,
/// The next slide's first frame, rendered in the background while this one is shown so /// The next slide's first frame, rendered in the background while this one is shown so
/// advancing only uploads it (#240). /// advancing only uploads it (#240).
prefetch: Option<Prefetch>, prefetch: Option<Prefetch>,
@@ -231,6 +235,10 @@ impl Show {
next_job: None, next_job: None,
#[cfg(test)] #[cfg(test)]
preview_spawns: 0, preview_spawns: 0,
remote_preview: None,
remote_next: None,
remote_sent: f64::NEG_INFINITY,
remote_opened: false,
prefetch: None, prefetch: None,
backdrop: None, backdrop: None,
blank: None, blank: None,
@@ -261,6 +269,27 @@ impl Show {
} }
} }
/// Play this slide's transition from the previous slide (or black for slide 0) as a preview (#1654).
pub fn start_transition_preview(&mut self, doc: &Presentation, now: f64) {
let i = self.state.slide;
let from = i.checked_sub(1);
let (kind, option, dur) = doc
.slides
.get(i)
.and_then(|s| s.transition.as_ref())
.map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0))
.unwrap_or(("none".into(), String::new(), 0.0));
self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation {
Some(Transition { old: None, from, morph: None, parts: None, start: now, kind, option, dur, reverse: false })
} else {
None
};
self.preview_only = true;
self.slide_start = now;
self.step_start = now;
self.step_end = now;
}
/// Identity of the show state a queued window-point event belongs to. /// Identity of the show state a queued window-point event belongs to.
pub(crate) fn input_state(&self) -> ShowInputState { pub(crate) fn input_state(&self) -> ShowInputState {
let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 }; let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 };
+18 -3
View File
@@ -12,6 +12,22 @@ pub(super) fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation,
.filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None }) .filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None })
.collect() .collect()
}); });
let (keep, consumed) = handle_keys(show, doc, now, keys, presenter);
for (key, modifiers) in consumed {
ctx.input_mut(|i| i.consume_key(modifiers, key));
ctx.request_repaint_of(egui::ViewportId::ROOT);
}
keep
}
pub(super) fn handle_keys(
show: &mut Show,
doc: &Presentation,
now: f64,
keys: Vec<(egui::Key, egui::Modifiers)>,
presenter: bool,
) -> (bool, Vec<(egui::Key, egui::Modifiers)>) {
let mut consumed = vec![];
let mut keep = true; let mut keep = true;
let mut actions = vec![]; let mut actions = vec![];
for (k, m) in keys { for (k, m) in keys {
@@ -31,15 +47,14 @@ pub(super) fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation,
} }
// The same key must not also activate a focused presenter button or be handled again // The same key must not also activate a focused presenter button or be handled again
// by an embedded presenter viewport. // by an embedded presenter viewport.
ctx.input_mut(|i| i.consume_key(m, k)); consumed.push((k, m));
ctx.request_repaint_of(egui::ViewportId::ROOT);
} }
for action in actions { for action in actions {
if !show.apply(action, doc, now) { if !show.apply(action, doc, now) {
keep = false; keep = false;
} }
} }
keep (keep, consumed)
} }
/// Whether the grid that the presenter key G opens is showing in this viewport. /// Whether the grid that the presenter key G opens is showing in this viewport.
+94
View File
@@ -1123,6 +1123,66 @@ fn input_state_tracks_the_modes_that_move_show_click_targets() {
assert_ne!(show.input_state(), base, "a blank screen has no slide click targets"); assert_ne!(show.input_state(), base, "a blank screen has no slide click targets");
} }
#[test]
fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
use crate::presenter::{Command, Host, Snapshot};
use std::{cell::RefCell, rc::Rc};
#[derive(Default)]
struct Bridge {
opens: usize,
closes: usize,
commands: Vec<Command>,
}
struct Mock(Rc<RefCell<Bridge>>);
impl Host for Mock {
fn open(&mut self) -> Result<(), String> {
self.0.borrow_mut().opens += 1;
Ok(())
}
fn is_open(&self) -> bool {
true
}
fn commands(&mut self) -> Vec<Command> {
std::mem::take(&mut self.0.borrow_mut().commands)
}
fn update(&mut self, _: Snapshot) {}
fn close(&mut self) {
self.0.borrow_mut().closes += 1;
}
}
let bridge = Rc::new(RefCell::new(Bridge::default()));
let mut session = deckcraft_engine::Session::with_new();
session.execute("slide.new", &json!({})).unwrap();
session.execute("slide.new", &json!({})).unwrap();
let services = crate::Services { presenter: Some(Box::new(Mock(bridge.clone()))), ..Default::default() };
let mut app = SlideApp::new(session, services);
app.ui.presenter_view = true;
let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(3);
h.state_mut().start_show(0, false);
h.run_steps(2);
assert_eq!(bridge.borrow().opens, 1);
for (key, slide) in [(egui::Key::ArrowRight, 1), (egui::Key::ArrowLeft, 0), (egui::Key::End, 2), (egui::Key::Home, 0)] {
bridge.borrow_mut().commands.push(Command::Key { name: key.name().into(), command: false, shift: false, alt: false, ctrl: false });
h.run_steps(2);
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide);
}
bridge.borrow_mut().commands.push(Command::End);
h.run_steps(2);
assert!(h.state().show.is_none());
assert_eq!(bridge.borrow().closes, 1);
h.state_mut().start_show(1, false);
h.run_steps(2);
assert_eq!(bridge.borrow().opens, 2);
assert_eq!(h.state().show.as_ref().unwrap().state.slide, 1);
}
/// #964: the next-slide preview renders on another thread; the presenter frame that asks for /// #964: the next-slide preview renders on another thread; the presenter frame that asks for
/// it doesn't wait, and the preview arrives on a later frame. /// it doesn't wait, and the preview arrives on a later frame.
#[test] #[test]
@@ -1238,3 +1298,37 @@ fn a_preview_of_an_edited_presentation_is_rendered_again() {
let tex = harness.state().1.next_tex.as_ref().map(|(d, k, _)| (std::sync::Arc::ptr_eq(d, &edited), *k)); let tex = harness.state().1.next_tex.as_ref().map(|(d, k, _)| (std::sync::Arc::ptr_eq(d, &edited), *k));
assert_eq!(tex, Some((true, 1)), "the preview comes from the edited presentation"); assert_eq!(tex, Some((true, 1)), "the preview comes from the edited presentation");
} }
/// #1654: Preview on the Transitions ribbon plays the current slide's transition from the
/// previous slide into the current slide, and exits when finished.
#[test]
fn transition_preview_plays_from_the_previous_slide() {
let mut s = deckcraft_engine::Session::with_new();
s.execute("slide.new", &json!({})).unwrap();
s.execute("transition.set", &json!({"kind": "push", "index": 1, "duration": 1000})).unwrap();
let doc = s.active().unwrap().doc.clone();
let mut show = Show::new(&doc, 1, true, false);
show.start_transition_preview(&doc, 10.0);
assert!(show.preview_only);
let tr = show.trans.as_ref().expect("transition preview is playing");
assert_eq!(tr.kind, "push");
assert_eq!(tr.from, Some(0), "enters from slide 0 into slide 1");
assert_eq!(tr.dur, 1.0);
assert!(!tr.reverse);
assert_eq!(tr.start, 10.0);
}
/// #1654: Transition preview for slide 0 plays with from = None (transitioning from black).
#[test]
fn transition_preview_for_first_slide_transitions_from_black() {
let mut s = deckcraft_engine::Session::with_new();
s.execute("transition.set", &json!({"kind": "fade", "index": 0, "duration": 500})).unwrap();
let doc = s.active().unwrap().doc.clone();
let mut show = Show::new(&doc, 0, true, false);
show.start_transition_preview(&doc, 5.0);
assert!(show.preview_only);
let tr = show.trans.as_ref().expect("fade transition preview is playing");
assert_eq!(tr.kind, "fade");
assert_eq!(tr.from, None, "slide 0 transitions from black (None)");
assert_eq!(tr.dur, 0.5);
}
+91 -7
View File
@@ -1,8 +1,8 @@
use super::keys::keyboard; use super::keys::{handle_keys, keyboard};
use super::media::{ use super::media::{
Pick, Under, gif_hit, media_bar_target, media_hit, on_screen, pointer_on_media_bar, run_media_cues, shapes_under_pointer, show_media, Pick, Under, gif_hit, media_bar_target, media_hit, on_screen, pointer_on_media_bar, run_media_cues, shapes_under_pointer, show_media,
}; };
use super::presenter::{PresenterTimer, presenter_window}; use super::presenter::{PresenterTimer, format_elapsed, presenter_window};
use super::render::{below_playing_video, fit, part_textures, quad_mesh, render_frame, render_overlay, render_threads, start_prefetch}; use super::render::{below_playing_video, fit, part_textures, quad_mesh, render_frame, render_overlay, render_threads, start_prefetch};
use super::zoom::{ use super::zoom::{
Zoom, grid_indices, norm_on_slide, paint_grid, see_all_hit, slide_aspect, slide_box, slide_to_screen, toggle_blank, tool_bar_rect, unit_uv, Zoom, grid_indices, norm_on_slide, paint_grid, see_all_hit, slide_aspect, slide_box, slide_to_screen, toggle_blank, tool_bar_rect, unit_uv,
@@ -10,7 +10,7 @@ use super::zoom::{
}; };
use super::{ use super::{
Align2, BackKey, Click, Color32, CornerRadius, Key, Layer, LinkJump, Pos2, Rect, RenderOpts, Sense, ShapeId, ShapeState, Show, ShowAction, Align2, BackKey, Click, Color32, CornerRadius, Key, Layer, LinkJump, Pos2, Rect, RenderOpts, Sense, ShapeId, ShapeState, Show, ShowAction,
SlideApp, Source, Stroke, TextureHandle, Ui, json, pos2, show_fills_screen, state_for, theme, vec2, viewport_visible, ShowState, SlideApp, Source, Stroke, TextureHandle, Ui, json, pos2, show_fills_screen, state_for, theme, vec2, viewport_visible,
}; };
use deckcraft_anim::{StepStamp, TriggerPlay}; use deckcraft_anim::{StepStamp, TriggerPlay};
use deckcraft_model::Presentation; use deckcraft_model::Presentation;
@@ -58,6 +58,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();
let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else { let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else {
app.show = None; app.show = None;
if let Some(host) = app.services.presenter.as_mut() {
host.close();
}
return; return;
}; };
let now = ctx.input(|i| i.time); let now = ctx.input(|i| i.time);
@@ -71,8 +74,35 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
return; return;
} }
show.observe_visible_frame(now, web_time::Instant::now()); show.observe_visible_frame(now, web_time::Instant::now());
begin_show(&ctx, &mut show, &doc, now); begin_show(&ctx, &mut show, &doc, now, app.services.presenter.is_some());
let mut out = Out { keep: keyboard(&ctx, &mut show, &doc, now, false), actions: vec![] }; let mut out = Out { keep: keyboard(&ctx, &mut show, &doc, now, false), actions: vec![] };
if show.presenter
&& !show.reading
&& let Some(host) = app.services.presenter.as_mut()
{
if !show.remote_opened {
show.remote_opened = true;
if let Err(message) = host.open() {
app.status = Some((message, crate::now_ms()));
}
}
for command in host.commands() {
match command {
crate::presenter::Command::End => out.keep = false,
crate::presenter::Command::Zoom { x, y } if x.is_finite() && y.is_finite() && show.zoom_arm => {
show.zoom = Some(Zoom::at(x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
show.zoom_arm = false;
}
crate::presenter::Command::Key { name, command, shift, alt, ctrl } => {
if let Some(key) = egui::Key::ALL.iter().copied().find(|key| key.name() == name) {
let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl };
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0;
}
}
_ => {}
}
}
}
let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0)); let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0));
if moved { if moved {
show.last_move = now; show.last_move = now;
@@ -94,6 +124,43 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
draw_pen_and_hints(ui, &mut show, &f, &painter, pointing); draw_pen_and_hints(ui, &mut show, &f, &painter, pointing);
draw_bars(ui, &mut show, &f, &painter, &mut out); draw_bars(ui, &mut show, &f, &painter, &mut out);
draw_extras(&mut show, &f, &painter, &mut out); draw_extras(&mut show, &f, &painter, &mut out);
if out.keep
&& show.presenter
&& !show.reading
&& let Some(host) = app.services.presenter.as_mut()
{
if now - show.remote_sent >= 0.2 {
let next = show.state.peek_next(&doc);
if let Some(n) = next
&& show.remote_next.as_ref().is_none_or(|(old, _)| *old != n)
{
let size =
crate::textures::fit_size((480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), 640);
let image = deckcraft_render::render_slide(
&doc,
n,
&RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), ..Default::default() },
);
show.remote_next = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)).map(|image| (n, image));
}
host.update(crate::presenter::Snapshot {
slide: show.state.slide,
total: doc.slides.len(),
ended: show.ended,
notes: doc.slides.get(show.state.slide).map(|s| s.notes_text()).unwrap_or_default(),
elapsed: format_elapsed(show.timer.elapsed(now)),
paused: show.timer.paused(),
notes_px: show.notes_px,
blank: show.blank_name().map(str::to_owned),
grid: show.grid,
zoom_armed: show.zoom_arm,
current: show.remote_preview.clone(),
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next).map(|(_, image)| image.clone()),
});
show.remote_sent = now;
}
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
finish(app, show, &f, out.keep); finish(app, show, &f, out.keep);
} }
@@ -110,7 +177,7 @@ fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) {
} }
} }
fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) { fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, remote: bool) {
if !show.started { if !show.started {
show.started = true; show.started = true;
show.slide_start = now; show.slide_start = now;
@@ -121,7 +188,7 @@ fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64
show.show_start = now; show.show_start = now;
show.timer = PresenterTimer::start(now); show.timer = PresenterTimer::start(now);
show.last_move = now; show.last_move = now;
if show_fills_screen(show.reading, &doc.show.show_type) { if show_fills_screen(show.reading, &doc.show.show_type) && !(show.presenter && remote) {
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true));
} }
show.after_enter(doc, now); show.after_enter(doc, now);
@@ -303,6 +370,9 @@ fn frame_texture(show: &mut Show, f: &Fr, sl: &Sl) -> Option<TextureHandle> {
render_frame(doc, idx, tl, step, sl.t, size, sl.hidden.clone(), &sl.times, render_threads(), &sl.plays, &sl.stamps, back.as_deref()) render_frame(doc, idx, tl, step, sl.t, size, sl.hidden.clone(), &sl.times, render_threads(), &sl.plays, &sl.stamps, back.as_deref())
} }
}; };
if show.remote_opened && show.presenter {
show.remote_preview = crate::presenter::Preview::from_image(&ci);
}
let tex = match show.tex.take() { let tex = match show.tex.take() {
Some((_, mut h)) => { Some((_, mut h)) => {
crate::textures::replace(ctx, &mut h, ci); crate::textures::replace(ctx, &mut h, ci);
@@ -388,6 +458,17 @@ fn draw_transition(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, te
if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) { if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) {
tr.parts = Some(part_textures(ctx, doc, tr.from, idx, size)); tr.parts = Some(part_textures(ctx, doc, tr.from, idx, size));
} }
if tr.old.is_none()
&& let Some(prev) = tr.from
&& !deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option)
{
let threads =
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) };
let timeline = ShowState::timeline(doc, prev);
let step = timeline.steps();
let ci = render_frame(doc, prev, timeline, step, 0.0, size, vec![], &[], threads, &[], &[], None);
tr.old = Some(crate::textures::upload(ctx, "trans-old", ci));
}
let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0);
let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten());
let parts = [part(0), part(1), part(2), part(3)]; let parts = [part(0), part(1), part(2), part(3)];
@@ -730,7 +811,7 @@ fn draw_extras(show: &mut Show, f: &Fr, painter: &egui::Painter, out: &mut Out)
ctx.request_repaint_after(std::time::Duration::from_millis(100)); ctx.request_repaint_after(std::time::Duration::from_millis(100));
} }
// Presenter view in a second window. // Presenter view in a second window.
if out.keep && show.presenter && !show.reading { if out.keep && show.presenter && !show.reading && !show.remote_opened {
presenter_window(ctx, show, doc, now, &mut out.keep); presenter_window(ctx, show, doc, now, &mut out.keep);
} }
} }
@@ -740,6 +821,9 @@ fn finish(app: &mut SlideApp, mut show: Show, f: &Fr, keep: bool) {
if keep { if keep {
app.show = Some(show); app.show = Some(show);
} else { } else {
if let Some(host) = app.services.presenter.as_mut() {
host.close();
}
app.media.stop_owned(crate::media::Owner::Show); app.media.stop_owned(crate::media::Owner::Show);
if !show.reading { if !show.reading {
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false));
+46 -1
View File
@@ -999,7 +999,8 @@ fn the_language_dialog_marks_the_selected_text() {
harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap();
// The anchored window settles over its first frames; click once it stays put. // The anchored window settles over its first frames; click once it stays put.
harness.run_steps(4); harness.run_steps(4);
harness.get_by_label("Français (France)").click(); // Installed dictionaries append a marker to the language's accessible label.
harness.get_by_label_contains("Français (France)").click();
harness.run_steps(2); harness.run_steps(2);
harness.get_by_label("OK").click(); harness.get_by_label("OK").click();
harness.run_steps(2); harness.run_steps(2);
@@ -2360,3 +2361,47 @@ fn overdue_visible_logic_does_not_reanchor_a_later_hidden_show() {
let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(3))); let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(3)));
assert_eq!(app.show.as_ref().unwrap().state.slide, 1, "time from the full visible frame is preserved through the logic-only pass"); assert_eq!(app.show.as_ref().unwrap().state.slide, 1, "time from the full visible frame is preserved through the logic-only pass");
} }
/// #1431: a comment card's reply row was wider than the pane, so the Comments pane grew every
/// frame until it stuck at its widest and dragging its edge did nothing.
#[test]
fn comments_pane_keeps_its_width_and_resizes() {
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(Session::with_new(), Services::default()),
);
h.run_steps(3);
h.state_mut().run("comment.add", json!({"text": "Check the figures on this slide"})).unwrap();
h.state_mut().run("comment.add", json!({"text": "Resolved one"})).unwrap();
h.state_mut().run("comment.resolve", json!({"index": 1, "resolved": true})).unwrap();
h.state_mut().run("view.pane", json!({"pane": "comments"})).unwrap();
h.run_steps(30);
let width = h.state().ui.pane_width;
assert!(width < 320.0, "the pane keeps its default width, not {width}");
// The cards stay compact: the second one starts near the top, not below a stretched first.
let second = h.get_by_label("Resolved one").rect();
assert!(second.top() < 450.0, "the second comment sits near the top, at {second:?}");
// Drag the pane's left edge: right narrows it, left widens it.
let drag = |h: &mut egui_kittest::Harness<'_, SlideApp>, dx: f32| {
let start = egui::pos2(1200.0 - h.state().ui.pane_width - 1.0, 500.0);
let end = start + egui::vec2(dx, 0.0);
h.event(egui::Event::PointerMoved(start));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
for i in 1..=6 {
h.event(egui::Event::PointerMoved(start + (end - start) * (i as f32 / 6.0)));
h.run_steps(1);
}
h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(10);
h.state().ui.pane_width
};
let narrow = drag(&mut h, 50.0);
assert!(narrow < width - 40.0, "dragging right narrows the pane: {width} -> {narrow}");
let wide = drag(&mut h, -150.0);
assert!(wide > narrow + 140.0, "dragging left widens the pane: {narrow} -> {wide}");
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Transitions and animations: parity with PowerPoint # Transitions and animations: parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** minor (going back in the show reverses the left slide's transition, #1531; previous: minor, Animation Pane ▸ Play From plays from the selected effect; previous: community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (animation Effect Options translate stored codes into readable labels; previous: minor, going back in the show reverses the left slide's transition, #1531; previous: minor, Animation Pane ▸ Play From plays from the selected effect; previous: community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Transitions and animations are what make a deck a presentation, and the part of PowerPoint people Transitions and animations are what make a deck a presentation, and the part of PowerPoint people
notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md) notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md)
@@ -65,7 +65,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
| Animation feature | PowerPoint | DeckCraft | | Animation feature | PowerPoint | DeckCraft |
|---|---|---| |---|---|---|
| Start On Click / With Previous / After Previous, duration, delay | yes | yes | | Start On Click / With Previous / After Previous, duration, delay | yes | yes |
| Effect Options (direction, sequence by paragraph) | yes | yes | | Effect Options (direction, sequence by paragraph) | yes | yes; every gallery option has a readable label while retaining its stored code |
| Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; repeat, bounce end and rewind round-trip | | Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; repeat, bounce end and rewind round-trip |
| By-paragraph text builds, sub-bullets follow parents | yes | yes; builds edited in PowerPoint (own start per paragraph, other effects in between, by 2nd-level paragraphs) import as one effect per paragraph and keep their clicks (#844) | | By-paragraph text builds, sub-bullets follow parents | yes | yes; builds edited in PowerPoint (own start per paragraph, other effects in between, by 2nd-level paragraphs) import as one effect per paragraph and keep their clicks (#844) |
| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step. Separate click steps, mixed starts and paragraph builds survive PPTX save (`trigger_steps.rs`) | | Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step. Separate click steps, mixed starts and paragraph builds survive PPTX save (`trigger_steps.rs`) |
@@ -94,6 +94,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-30 | trivial | Animation Effect Options show readable, effect-aware labels for all gallery option codes; stored values and command parameters stay unchanged |
| 2026-10-29 | minor | Going back in the show plays the transition of the slide being left, reversed, instead of the previous slide's entrance transition (#1531); new row in the transition features. Scores unchanged | | 2026-10-29 | minor | Going back in the show plays the transition of the slide being left, reversed, instead of the previous slide's entrance transition (#1531); new row in the transition features. Scores unchanged |
| 2026-10-22 | minor | Animation Pane ▸ Play From: with an effect selected, the preview starts at that effect on the play-all clock; the row says yes and the estimate drops to ~14–26 h | | 2026-10-22 | minor | Animation Pane ▸ Play From: with an effect selected, the preview starts at that effect on the play-all clock; the row says yes and the estimate drops to ~14–26 h |
| 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores | | 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores |
+2 -1
View File
@@ -1,6 +1,6 @@
# Where DeckCraft falls short of PowerPoint # Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-10-30 · **Change:** minor (slide-show hit testing follows each shape’s own animation state; scale and rotation covered; previous: trivial, AutoFormat lists run independently of Replace text as you type; previous: minor, slide-show click targets follow animated shapes and text links; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-30 · **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)
Every known shortfall, one entry each, ranked by how much it stops someone who presents with Every known shortfall, one entry each, ranked by how much it stops someone who presents with
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
@@ -368,6 +368,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
| 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added | | 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added |
| 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated | | 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated |
| 2026-10-27 | minor | Refreshed against merged PRs: removed the Morph alignment entry (#67 merged 2026-10-14); #77 (animation rewind, PR #83) is fixed, leaving #74 in the round-trip gap; #75 was closed unmerged, so #60 stays open with no fix PR; #53 is closed and the image-paste gap is now Windows #1414; Reuse Slides (#1103) and Compare reject (#850) noted in the smaller rows; the duplicate header line is gone | | 2026-10-27 | minor | Refreshed against merged PRs: removed the Morph alignment entry (#67 merged 2026-10-14); #77 (animation rewind, PR #83) is fixed, leaving #74 in the round-trip gap; #75 was closed unmerged, so #60 stays open with no fix PR; #53 is closed and the image-paste gap is now Windows #1414; Reuse Slides (#1103) and Compare reject (#850) noted in the smaller rows; the duplicate header line is gone |
+12 -1
View File
@@ -2,6 +2,10 @@
> **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last updated:** 2026-11-02 · Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row re-assessed: remains P, since only how tiles are sized changed. Scores unchanged.
>
> **Previously (2026-11-01):** Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged.
The summary between `<!-- SUMMARY --> The summary between `<!-- SUMMARY -->
**Weighted breadth parity: 84%** over 191 features. **Weighted breadth parity: 84%** over 191 features.
@@ -118,7 +122,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Shapes | Text warp transforms | P2 | P | | Shapes | Text warp transforms | P2 | P |
| Shapes | Shape quick styles | P1 | D | | Shapes | Shape quick styles | P1 | D |
| Shapes | Set as default shape | P2 | D | | Shapes | Set as default shape | P2 | D |
| Format | Solid fill and transparency | P0 | D | | Format | Solid fill and transparency (No Fill interiors pass clicks through, #1621) | P0 | D |
| Format | Gradient fill (linear, radial, rectangular, path) | P0 | D | | Format | Gradient fill (linear, radial, rectangular, path) | P0 | D |
| Format | Picture and texture fill | P1 | P | | Format | Picture and texture fill | P1 | P |
| Format | Pattern fill | P2 | P | | Format | Pattern fill | P2 | P |
@@ -233,3 +237,10 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Platforms (✱) | macOS, Windows, Linux builds | P0 | P | | Platforms (✱) | macOS, Windows, Linux builds | P0 | P |
| Platforms (✱) | Web (WASM) build | P1 | D | | Platforms (✱) | Web (WASM) build | P1 | D |
| Platforms (✱) | BSD build | P2 | P | | Platforms (✱) | BSD build | P2 | P |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | trivial | Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row remains P and summary scores are unchanged |
| 2026-10-31 | trivial | No Fill interior hit-testing follows resolved fill (#1621); fill row remains D and summary scores are unchanged |
+9 -5
View File
@@ -1,6 +1,7 @@
# UI parity with PowerPoint # UI parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (Picture Format: the Picture Styles row leaves room for Crop and Size, #1232; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **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 (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-26 · **Change:** trivial (dragging several selected slides moves them all; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (dragging several selected slides moves them all; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -28,7 +29,7 @@ ends and are omitted from thumbnails, slide shows and exports.
| Colour menus: theme and standard colours, hex field, Eyedropper | yes (plus a More Colors dialog) | yes: theme and standard colours, hex field and an Eyedropper that takes the colour drawn under the pointer on the slide (#367) | More Colors dialog; Eyedropper only takes colours from the slide, not the rest of the screen | | Colour menus: theme and standard colours, hex field, Eyedropper | yes (plus a More Colors dialog) | yes: theme and standard colours, hex field and an Eyedropper that takes the colour drawn under the pointer on the slide (#367) | More Colors dialog; Eyedropper only takes colours from the slide, not the rest of the screen |
| Contextual tabs: Shape Format, Picture Format, Table Design, Table Layout, Chart Design, Chart Format, Video/Audio Format and Playback | yes | most; checklist P0 row partial | Chart Format, media tabs thinner | | Contextual tabs: Shape Format, Picture Format, Table Design, Table Layout, Chart Design, Chart Format, Video/Audio Format and Playback | yes | most; checklist P0 row partial | Chart Format, media tabs thinner |
| File tab (Backstage): Home, New, Open, Info, Save, Save As, Print, Share, Export, Close, Account, Options | yes | yes (#76), see [File tab](#file-tab-backstage); no Share (cloud) | Video/GIF/CD exports, templates beyond the built-in themes | | File tab (Backstage): Home, New, Open, Info, Save, Save As, Print, Share, Export, Close, Account, Options | yes | yes (#76), see [File tab](#file-tab-backstage); no Share (cloud) | Video/GIF/CD exports, templates beyond the built-in themes |
| Start screen: new, themes, templates, recent, open | yes, the same page as File › Home | yes: File › Home with Home, New, Open, Account and Options (#76) | | | Start screen: new, themes, templates, recent, open | yes, the same page as File › Home | yes: File › Home with Home, New, Open, About and Options (#76) | |
| Status bar: slide counter, language, notes, comments, views, zoom slider | yes | yes | | | Status bar: slide counter, language, notes, comments, views, zoom slider | yes | yes | |
| Command search (Tell Me) | yes | command palette | | | Command search (Tell Me) | yes | command palette | |
| Light / dark appearance | follows the system | light or dark; doesn't follow the system (#41) | 1–2 h | | Light / dark appearance | follows the system | light or dark; doesn't follow the system (#41) | 1–2 h |
@@ -45,7 +46,8 @@ Close and, at the bottom under a rule, Account and Options. Entries are 40 point
outline icon; the current page is drawn in the accent colour and outlined. Esc goes back. Alt+F opens outline icon; the current page is drawn in the accent colour and outlined. Esc goes back. Alt+F opens
on Home with the keyboard in the side menu; Up and Down move through it and a page shows as soon as on Home with the keyboard in the side menu; Up and Down move through it and a page shows as soon as
its entry has the focus. PowerPoint's start screen is the same Home page, with only Home, New and its entry has the focus. PowerPoint's start screen is the same Home page, with only Home, New and
Open at the top. DeckCraft follows all of this (`crates/ui-egui/src/backstage.rs`), with the page and Open at the top. DeckCraft follows all of this (`crates/ui-egui/src/backstage.rs`), except that
Account is About, with the page and
menu colours taken from the theme tokens (menu `chrome`, page `card`). menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Page | PowerPoint | DeckCraft | | Page | PowerPoint | DeckCraft |
@@ -60,7 +62,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Share | Cloud sharing | Not offered | | Share | Cloud sharing | Not offered |
| Export | Create PDF/XPS Document, Create a Video, Create an Animated GIF, Package Presentation for CD, Create Handouts, Change File Type | Create PDF Document, Create Handouts (a PDF), Change File Type (PNG with an optional transparent background, #784). No video, GIF or CD package | | Export | Create PDF/XPS Document, Create a Video, Create an Animated GIF, Package Presentation for CD, Create Handouts, Change File Type | Create PDF Document, Create Handouts (a PDF), Change File Type (PNG with an optional transparent background, #784). No video, GIF or CD package |
| Close | Closes; asks Save / Don't Save / Cancel about unsaved changes | Same (`file.close` after the prompt); Ctrl+W asks too | | Close | Closes; asks Save / Don't Save / Cancel about unsaved changes | Same (`file.close` after the prompt); Ctrl+W asks too |
| Account | User information, sign-in, Office Background and Office Theme, product information, About | Appearance (light/dark, `view.dark`), product information, About DeckCraft. No accounts to sign in to | | Account | User information, sign-in, Office Background and Office Theme, product information, About | About: product information, project links, contributor/model credits and appearance (light/dark, `view.dark`). No Account entry |
| Options | Opens the PowerPoint Options dialog | Opens Preferences | | Options | Opens the PowerPoint Options dialog | Opens Preferences |
## Panes and dialogs ## Panes and dialogs
@@ -81,7 +83,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Behaviour | PowerPoint | DeckCraft | | Behaviour | PowerPoint | DeckCraft |
|---|---|---| |---|---|---|
| Selection handles, rotation handle, yellow adjustment handles | yes | yes | | Selection handles, rotation handle, yellow adjustment handles | yes | yes; an empty No Fill shape passes interior clicks to underlying objects while its outline and handles remain selectable. Explicit, style and theme fills resolve as rendered; tests also cover locked outline targets and empty interiors without an outline (#1621) |
| Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes | | Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes |
| Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) | | Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) |
| Scroll bars on the slide pane when the zoomed slide is larger than the window | yes | yes, on each axis that overflows: drag the thumb or click the track; the wheel and trackpad still pan. The vertical bar does not turn the page (#1157) | | Scroll bars on the slide pane when the zoomed slide is larger than the window | yes | yes, on each axis that overflows: drag the thumb or click the track; the wheel and trackpad still pan. The vertical bar does not turn the page (#1157) |
@@ -171,6 +173,8 @@ consistency is open (#73, #56).
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
| 2026-10-29 | trivial | Picture Format: the Picture Styles row shows as many styles as fit beside the groups after it (at least two) and a More arrow with all 24, as the Transitions and Animations galleries do; Crop and Size are in view in the default window (#1232) | | 2026-10-29 | trivial | Picture Format: the Picture Styles row shows as many styles as fit beside the groups after it (at least two) and a More arrow with all 24, as the Transitions and Animations galleries do; Crop and Size are in view in the default window (#1232) |
| 2026-10-26 | trivial | Thumbnail pane and Slide Sorter: dragging one of several selected slides moves the whole selection | | 2026-10-26 | trivial | Thumbnail pane and Slide Sorter: dragging one of several selected slides moves the whole selection |
| 2026-10-24 | trivial | Table cells: dragging across cells selects a range of cells | | 2026-10-24 | trivial | Table cells: dragging across cells selects a range of cells |
+65
View File
@@ -0,0 +1,65 @@
# WASM presenter-view popout (#729)
Tracks https://github.com/storytold/deckcraft/issues/729 (the reporter moved duplicate #726 here).
## Implementation
The browser presenter opens the same DeckCraft web build in a separate, same-origin window.
That window starts its own eframe runner and renders only presenter controls, current/next
previews and notes. The audience window remains the sole owner of slideshow state, animations,
media and navigation. Presenter keyboard input uses the same navigation handler as the native
presenter, so an input is applied once rather than advancing two independent shows.
Messages check the exact origin, source window and a per-opening session identifier. Input
queues and preview dimensions are bounded. Previews use the audience's rendered current frame;
unchanged preview pixels are not retransmitted. Presenter updates are limited to five per second.
If the browser blocks the initial popup, an explicit Open presenter window button calls the
browser API directly from its click handler. Browser policy can still require the user to allow
popups, or choose to open a tab instead of a window. Closing the presenter leaves the audience
running and offers the same button to reopen it. Ending the show closes the presenter. The
presenter reports a closed or unresponsive audience rather than continuing an independent show.
Native presenter view continues to use its existing egui viewport.
No native second-display placement, Linux display-counting policy, laser/rehearsal changes or
other contributors' unmerged presenter work is included.
## Validation
Candidate implementation; not yet qualified for release. The current-main reconciliation and
transport/font corrections have had source inspection and static diff checks only. No local Rust
builds, tests or formatter were run for this candidate; hosted compilation and regression tests
remain pending. Earlier-head validation recorded in the PR does not qualify this changed tree.
The original bug was reproduced on the deployed 0.7.0 browser build on 2026-10-14; that
deployment's exact commit is unknown.
Regression coverage submitted for hosted execution checks successful image deduplication,
failed-send retries, session/reload refresh and clearing previews. The independent popup uses
the existing interface font definitions and UiFonts fallback discovery before laying out notes,
discarding font-install passes before input forwarding. Its font regression checks bundled
Arabic/Japanese/Hangul glyphs when those optional font inputs are present; missing optional
inputs do not establish script coverage.
Source inspection verifies these lifecycle paths: a blocked popup offers an explicit retry
whose click handler calls the browser API; closing the popup offers reopening; ending the show
closes the popup; incoming messages check origin, source window and opening-session token.
This establishes the intended control flow, not empirical compatibility with each browser.
Runtime checks that would add empirical coverage with a synthetic deck:
- Popup allowed and blocked, explicit retry, and independent window positioning.
- Initial popup behavior in Safari and Firefox, including whether explicit retry is needed on
each show start. These browser families have not been exercised for this candidate; missing
manual coverage alone is not evidence of a defect or a mandatory merge blocker.
- Next/Previous, Home/End, numeric navigation, and no double advance on Space/Enter.
- Current/next previews, notes, pause/reset, black/white screens, All Slides and zoom.
- Navigation from both windows, including animation steps and the end screen.
- Close/reopen the presenter, reload it, end/restart the show, and close/reload the audience.
- Rejection of messages from the wrong origin, source window or prior session.
- Native presenter regressions, WASM compilation and the full repository quality gates.
All runtime items above, including actual browser popup/reload/end-show execution, remain
unexecuted for this candidate. Shared native tests and source inspection do not constitute
browser-executed validation. Font subsets can legitimately omit individual script families;
the regression always checks installation/layout/discard handling, and checks a script's glyphs
only when its corresponding bundled face is present.