Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 17f9f40ede Notes: place bullets in one pass over the galley rows 2026-11-04 03:59:20 +00:00
Dylan M. Taylor 23ac4f7efd Fonts: bound font-controlled work in enumeration, outlines and carets (#1701)
* Fonts: bound font-controlled work in enumeration, outlines and carets

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

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

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

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

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

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

* engine: pass the test run's text by value
2026-11-03 19:59:51 -05:00
Dylan M. Taylor a72a5167d0 Morph: pick the next curve to split from a heap, not a full scan (#1782) 2026-11-03 19:59:29 -05:00
Dylan M. Taylor b7d57bacab Text edit: splice inserted paragraphs once and paste runs without rescanning (#1787) 2026-11-03 19:54:06 -05:00
Dylan M. Taylor 42108d0ee7 PPTX export: index animation targets, cache build roles, serialize run properties once per text run (#1788)
* PPTX export: index animation targets, cache a paragraph's build roles, serialize run properties once per text run

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

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

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

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

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

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

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

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

* UI: About review fixes: stack columns below 860pt, Help icon, Credits task; doc headers
2026-11-02 19:02:14 -05:00
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
122 changed files with 6435 additions and 601 deletions
Generated
+28 -28
View File
@@ -984,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"arboard",
"chrono",
@@ -1006,7 +1006,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -1015,7 +1015,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1024,14 +1024,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-cli"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -1041,14 +1041,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"chrono",
"deckcraft-anim",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -1086,7 +1086,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"deckcraft-model",
"serde",
@@ -1097,7 +1097,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"kurbo",
"serde",
@@ -1105,7 +1105,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1123,21 +1123,21 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-matroska"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-mcp"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1147,7 +1147,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1165,7 +1165,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"image",
"kurbo",
@@ -1174,7 +1174,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1186,15 +1186,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.14.1"
version = "0.15.0"
[[package]]
name = "deckcraft-opus"
version = "0.14.1"
version = "0.15.0"
[[package]]
name = "deckcraft-pdf"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1212,7 +1212,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1228,7 +1228,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1247,7 +1247,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1261,7 +1261,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -1286,7 +1286,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1295,7 +1295,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -6177,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.14.1"
version = "0.15.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.14.1"
version = "0.15.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+18 -1
View File
@@ -2,7 +2,9 @@
**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-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -148,6 +150,16 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order |
| 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves |
| 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged |
| 2026-11-02 | Deleting slides removes their IDs from every custom show; the cleanup is undoable and survives native save/reopen |
| 2026-11-02 | Reused slides scale their geometry and content dimensions to the destination slide, including kept source masters and layouts; the reuse picker still lacks thumbnails |
| 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences |
| 2026-11-02 | PPTX reflection effects keep their authored start position, affine scale/skew, direction and fade, alignment, and rotation flag through native and PPTX saves |
| 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables |
| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) |
| 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) |
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up |
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
@@ -318,6 +330,11 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged |
| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged |
| 2026-11-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-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 |
+1 -1
View File
@@ -27,4 +27,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] }
+2
View File
@@ -18,6 +18,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(target_arch = "wasm32")]
mod presenter;
#[cfg(target_arch = "wasm32")]
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);
}
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
if let Some(token) = crate::presenter::token() {
let presenter = crate::presenter::Popup::new(token, cc.egui_ctx.clone()).map_err(std::io::Error::other)?;
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
}
let inbox: Inbox = Inbox::default();
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let q = query();
@@ -80,6 +84,11 @@ impl eframe::App for WebShell {
/// Delegate one eframe logic pass to the wrapped application.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
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,
@@ -118,7 +127,15 @@ impl eframe::App for WebShell {
/// back to `open_async` (browser picker → inbox) and `download`.
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
let presenter = match crate::presenter::BrowserHost::new(ctx.clone()) {
Ok(host) => Some(Box::new(host) as Box<dyn deckcraft_ui_egui::presenter::Host>),
Err(error) => {
log::error!("Presenter window: {error}");
None
}
};
Services {
presenter,
open_async: Some(Box::new(move |purpose: &str, request: u64| {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
+59 -29
View File
@@ -1,5 +1,7 @@
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
use std::collections::{HashMap, HashSet, VecDeque};
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
@@ -15,48 +17,66 @@ fn text_of(s: &Shape) -> String {
/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on
/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used
/// once; top-level shapes only (groups move whole).
///
/// Each pass compares a key of the two shapes, so the shapes of `b` are indexed by key once per
/// pass: matching costs the sum of the two shape lists, not their product.
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
let mut used_a = vec![false; a.shapes.len()];
let mut used_b = vec![false; b.shapes.len()];
let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [
&|x, y| x.name.starts_with("!!") && x.name == y.name,
&|x, y| !x.name.is_empty() && x.name == y.name,
&|x, y| {
if x.kind_name() != y.kind_name() {
return false;
// Each shape's text, worked out once rather than inside every comparison.
let text_a: Vec<String> = a.shapes.iter().map(text_of).collect();
let text_b: Vec<String> = b.shapes.iter().map(text_of).collect();
let forced = |s: &Shape, _: &str| (s.name.starts_with("!!")).then(|| MorphKey::Name(s.name.clone()));
let named = |s: &Shape, _: &str| (!s.name.is_empty()).then(|| MorphKey::Name(s.name.clone()));
let same_text = |s: &Shape, t: &str| (!t.trim().is_empty()).then(|| MorphKey::Text(s.kind_name(), t.to_string()));
let same_geom = |s: &Shape, t: &str| match s.geom.preset_name() {
Some(g) if t.trim().is_empty() => Some(MorphKey::Geom(s.kind_name(), g.to_string())),
_ => None,
};
let passes: [&dyn Fn(&Shape, &str) -> Option<MorphKey>; 4] = [&forced, &named, &same_text, &same_geom];
for key in passes {
// Unused shapes of `b` by key, in slide order: the front of each queue is the first unused
// match, which is the one a search through `b` would find.
let mut index: HashMap<MorphKey, VecDeque<usize>> = HashMap::new();
for (j, sb) in b.shapes.iter().enumerate() {
if used_b.get(j).copied().unwrap_or(true) {
continue;
}
let tx = text_of(x);
!tx.trim().is_empty() && tx == text_of(y)
},
&|x, y| {
x.kind_name() == y.kind_name()
&& x.geom.preset_name().is_some()
&& x.geom.preset_name() == y.geom.preset_name()
&& text_of(x).trim().is_empty()
&& text_of(y).trim().is_empty()
},
];
for pass in passes {
if let Some(k) = key(sb, text_b.get(j).map_or("", String::as_str)) {
index.entry(k).or_default().push_back(j);
}
}
if index.is_empty() {
continue;
}
for (i, sa) in a.shapes.iter().enumerate() {
if used_a.get(i).copied().unwrap_or(true) {
continue;
}
let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb));
if let Some((j, sb)) = found {
if let Some(u) = used_a.get_mut(i) {
*u = true;
}
if let Some(u) = used_b.get_mut(j) {
*u = true;
}
pairs.push((sa.id, sb.id));
let Some(k) = key(sa, text_a.get(i).map_or("", String::as_str)) else { continue };
let Some(j) = index.get_mut(&k).and_then(VecDeque::pop_front) else { continue };
let Some(sb) = b.shapes.get(j) else { continue };
if let Some(u) = used_a.get_mut(i) {
*u = true;
}
if let Some(u) = used_b.get_mut(j) {
*u = true;
}
pairs.push((sa.id, sb.id));
}
}
pairs
}
/// What one [`morph_pairs`] pass matches shapes on.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum MorphKey {
Name(String),
Text(&'static str, String),
Geom(&'static str, String),
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
let v = a + (b - a) * t;
if v.is_finite() {
@@ -256,13 +276,23 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
f.shapes.push((sh, old));
id
};
// Lookups by id, built once (the first shape or pair with an id wins, as a search would).
let paired_a: HashSet<ShapeId> = pairs.iter().map(|(ia, _)| *ia).collect();
let mut partner: HashMap<ShapeId, ShapeId> = HashMap::with_capacity(pairs.len());
for (ia, ib) in &pairs {
partner.entry(*ib).or_insert(*ia);
}
let mut a_by_id: HashMap<ShapeId, &Shape> = HashMap::with_capacity(a.shapes.len());
for x in &a.shapes {
a_by_id.entry(x.id).or_insert(x);
}
for sa in &a.shapes {
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
if !paired_a.contains(&sa.id) {
push(sa.clone(), true, 1.0 - s);
}
}
for sb in &b.shapes {
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
let pair = partner.get(&sb.id).and_then(|ia| a_by_id.get(ia).copied());
let Some(sa) = pair else {
if !hidden_b(sb.id) {
push(sb.clone(), false, s);
+55
View File
@@ -1155,3 +1155,58 @@ fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
assert_eq!(tl.effects().len(), 6_000);
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
}
#[test]
fn morph_pairs_take_the_first_unused_match_in_slide_order() {
let mut a = Slide::default();
let mut b = Slide::default();
a.shapes = vec![shape(1, "Same"), shape(2, "Same"), shape(3, "Other")];
b.shapes = vec![shape(11, "Same"), shape(12, "Same"), shape(13, "Same")];
// By name first, in slide order; the shape of `a` left over pairs by kind and geometry with
// the shape of `b` left over.
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(11)), (ShapeId(2), ShapeId(12)), (ShapeId(3), ShapeId(13))]);
}
#[test]
fn morph_of_large_slides_stays_linear() {
let n = 20_000u32;
let text = |i: u32, side: &str| Some(TextBody::from_text(&format!("{side} {i}")));
let mut a = Slide::default();
let mut b = Slide::default();
// Text no shape on the other slide shares: every pass looks for a partner and finds none.
a.shapes = (0..n).map(|i| Shape { text: text(i, "a"), ..shape(i + 1, "") }).collect();
b.shapes = (0..n).map(|i| Shape { text: text(i, "b"), ..shape(n + i + 1, "") }).collect();
// One named pair at opposite ends.
a.shapes[0].name = "Logo".into();
b.shapes[n as usize - 1].name = "Logo".into();
// No wall-clock bound (it would flake on a loaded runner): at this size a quadratic pairing
// takes minutes, so a regression shows as a test that hangs rather than a failed assertion.
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2 * n))]);
let p = deckcraft_model::defaults::new_presentation(None);
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
// Every shape fades in place; the named pair differs in text, so it cross-fades as two.
assert_eq!(f.shapes.len(), 2 * n as usize);
}
#[test]
fn paragraph_states_of_a_large_build_stay_linear() {
let n = 20_000usize;
let mut s = slide_with(vec![]);
let mut body = TextBody::from_text(&vec!["line"; n].join("\n"));
// One first-level paragraph owning every other paragraph.
for p in body.paragraphs.iter_mut().skip(1) {
p.level = 1;
}
s.shapes[0].text = Some(body);
let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick);
a.text_build = TextBuild::ByParagraph;
s.animations = vec![a];
let tl = Timeline::new(&s, W, H);
// No wall-clock bound, as above: a per-paragraph scan of the slide would take minutes here.
assert_eq!(tl.paragraph_targets(ShapeId(1)).len(), n);
for p in 0..n {
assert!(!tl.state(ShapeId(1), Some(p), 0, 0.0).visible);
assert!(tl.state(ShapeId(1), Some(p), 1, 0.0).visible);
}
assert_eq!(tl.animated_shapes(), [ShapeId(1)]);
}
+47 -12
View File
@@ -103,6 +103,41 @@ pub struct Timeline {
/// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent:
/// (shape, member paragraph, owner paragraph).
groups: Vec<(ShapeId, usize, usize)>,
/// Lookups built with the timeline, so evaluating a shape or paragraph costs its own effects
/// rather than a scan of every effect, group and colour on the slide.
index: TimelineIndex,
}
#[derive(Clone, Debug, Default)]
struct TimelineIndex {
/// Effects (indices into `effects`, in order) by target shape and paragraph.
by_target: HashMap<(ShapeId, Option<usize>), Vec<usize>>,
/// Effects (indices into `effects`, in order) by target shape.
by_shape: HashMap<ShapeId, Vec<usize>>,
/// Owner paragraph of each member paragraph (the first group recorded wins).
owner: HashMap<(ShapeId, usize), usize>,
/// Groups (member, owner) by shape, in order.
groups_by_shape: HashMap<ShapeId, Vec<(usize, usize)>>,
/// Base colour by shape (the first recorded wins).
color: HashMap<ShapeId, Rgba>,
}
impl TimelineIndex {
fn build(effects: &[Effect], groups: &[(ShapeId, usize, usize)], colors: &[(ShapeId, Rgba)]) -> TimelineIndex {
let mut ix = TimelineIndex::default();
for (i, e) in effects.iter().enumerate() {
ix.by_target.entry((e.timing.shape, e.timing.paragraph)).or_default().push(i);
ix.by_shape.entry(e.timing.shape).or_default().push(i);
}
for &(shape, member, owner) in groups {
ix.owner.entry((shape, member)).or_insert(owner);
ix.groups_by_shape.entry(shape).or_default().push((member, owner));
}
for &(shape, c) in colors {
ix.color.entry(shape).or_insert(c);
}
ix
}
}
enum Status {
@@ -324,6 +359,7 @@ impl Timeline {
};
tl.colors.push((id, c));
}
tl.index = TimelineIndex::build(&tl.effects, &tl.groups, &tl.colors);
tl
}
@@ -413,8 +449,12 @@ impl Timeline {
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec<usize> {
let mut v: Vec<usize> = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect();
v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::<Vec<_>>());
let mine = self.index.by_shape.get(&shape).map_or(&[][..], Vec::as_slice);
let mut v: Vec<usize> = mine.iter().filter_map(|&i| self.effects.get(i)).filter_map(|e| e.timing.paragraph).collect();
let owners: HashSet<usize> = v.iter().copied().collect();
if let Some(groups) = self.index.groups_by_shape.get(&shape) {
v.extend(groups.iter().filter(|(_, owner)| owners.contains(owner)).map(|(member, _)| *member));
}
v.sort_unstable();
v.dedup();
v
@@ -422,13 +462,8 @@ impl Timeline {
/// Shapes with any effect.
pub fn animated_shapes(&self) -> Vec<ShapeId> {
let mut v: Vec<ShapeId> = Vec::new();
for e in &self.effects {
if !v.contains(&e.timing.shape) {
v.push(e.timing.shape);
}
}
v
let mut seen: HashSet<ShapeId> = HashSet::new();
self.effects.iter().map(|e| e.timing.shape).filter(|s| seen.insert(*s)).collect()
}
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
@@ -456,9 +491,9 @@ impl Timeline {
triggers: &[TriggerPlay],
stamps: &[StepStamp],
) -> AnimState {
let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let para = para.map(|p| self.index.owner.get(&(shape, p)).copied().unwrap_or(p));
let mine = self.index.by_target.get(&(shape, para)).map_or(&[][..], Vec::as_slice).iter().filter_map(|&i| self.effects.get(i));
let base_color = self.index.color.get(&shape).copied().unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let mut st = AnimState::default();
// Initial visibility comes from the first effect in the Animation Pane order.
let mut ordered: Vec<&Effect> = mine.collect();
+4 -1
View File
@@ -425,6 +425,8 @@ impl Decoder {
fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> {
let mut tu = 0usize;
// Repeated-frame outputs in `out`, counted as they are added rather than recounted per OBU.
let mut existing = out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count();
let mut pos = 0;
while pos < data.len() {
let obu = parse_obu_unit(data, pos)?;
@@ -444,9 +446,10 @@ impl Decoder {
}
OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => {
if let Some(o) = self.frame_obu(&obu)? {
if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 {
if existing >= 32 {
return Err(Error::Invalid("too many frame outputs in chunk"));
}
existing += usize::from(matches!(o, Output::Existing { .. }));
out.push((o, tu, spatial_id));
}
}
+131 -9
View File
@@ -116,16 +116,31 @@ fn pack(data: &[u8], crop: [f64; 4], w_pt: f64, h_pt: f64, ppi: f64, delete_crop
Some(Packed { bytes, content_type, crop: new_crop })
}
/// Picture shapes in `shapes` (groups included) that `want` accepts.
fn pictures_mut<'a>(shapes: &'a mut [Shape], want: &dyn Fn(ShapeId) -> bool, out: &mut Vec<&'a mut Shape>, depth: usize) {
/// Pictures accepted by `want`, with width and height measured in slide space through group transforms.
fn pictures_mut<'a>(
shapes: &'a mut [Shape],
want: &dyn Fn(ShapeId) -> bool,
out: &mut Vec<(&'a mut Shape, f64, f64)>,
to_slide: deckcraft_geom::Affine,
depth: usize,
) {
if depth > 64 {
return;
}
for sh in shapes {
if matches!(sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
out.push(sh);
} else if let Some(ch) = sh.children_mut() {
pictures_mut(ch, want, out, depth + 1);
let Some(xfrm) = sh.xfrm else { continue };
let local_to_slide = to_slide * xfrm.affine();
if matches!(&sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
let origin = local_to_slide * deckcraft_geom::Point::ZERO;
let across = local_to_slide * deckcraft_geom::Point::new(xfrm.w, 0.0);
let down = local_to_slide * deckcraft_geom::Point::new(0.0, xfrm.h);
out.push((sh, (across - origin).hypot(), (down - origin).hypot()));
continue;
}
if let ShapeKind::Group { children, child } = &mut sh.kind {
let child_rect = deckcraft_geom::Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
let child_to_slide = to_slide * deckcraft_geom::group_child_affine(&xfrm, child_rect);
pictures_mut(children, want, out, child_to_slide, depth + 1);
}
}
}
@@ -155,10 +170,9 @@ fn compress(s: &mut Session, p: &Value) -> Result<Value> {
let Some(slide) = doc.slides.get(i).cloned() else { continue };
let mut slide = slide.as_ref().clone();
let mut pics = vec![];
pictures_mut(&mut slide.shapes, &want, &mut pics, 0);
pictures_mut(&mut slide.shapes, &want, &mut pics, deckcraft_geom::Affine::IDENTITY, 0);
let mut touched = false;
for sh in pics {
let (w, h) = sh.xfrm.map(|x| (x.w, x.h)).unwrap_or((0.0, 0.0));
for (sh, w, h) in pics {
let ShapeKind::Picture { fill } = &mut sh.kind else { continue };
let PictureFill { media, crop, .. } = fill.clone();
let key = (media.0, crop.map(f64::to_bits), w.to_bits(), h.to_bits());
@@ -232,6 +246,114 @@ mod tests {
assert!(pack(b"<svg xmlns='http://www.w3.org/2000/svg'/>", [0.0; 4], 72.0, 72.0, 150.0, true).is_none());
}
#[test]
fn grouped_pictures_use_their_displayed_size() {
let mut s = Session::with_new();
let original = png(600, 600);
s.edit(|doc, _| {
let media = doc.add_media("grouped.png", "image/png", original.clone()).ok_or_else(|| bad("test", "media"))?;
let picture = Shape {
id: ShapeId(70_101),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
..Default::default()
};
let nested = Shape {
id: ShapeId(70_102),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
..Default::default()
};
let group = Shape {
id: ShapeId(70_103),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 720.0)),
kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
..Default::default()
};
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
Arc::make_mut(slide).shapes.push(group);
Ok(())
})
.unwrap();
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
assert_eq!(result["pictures"], 0, "a 600 px image displayed at 720×720 pt is already below the requested sampling density");
assert!(
s.doc().unwrap().doc.media.iter().any(|m| m.data.as_slice() == original.as_slice()),
"the under-target original media must remain untouched"
);
}
#[test]
fn anisotropic_nested_group_scaling_controls_sampling_dimensions() {
let mut s = Session::with_new();
let original = png(2000, 1000);
s.edit(|doc, _| {
let media = doc.add_media("scaled-group.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
let picture = Shape {
id: ShapeId(70_111),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
..Default::default()
};
let nested = Shape {
id: ShapeId(70_112),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
..Default::default()
};
let group = Shape {
id: ShapeId(70_113),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 360.0)),
kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
..Default::default()
};
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
Arc::make_mut(slide).shapes.push(group);
Ok(())
})
.unwrap();
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
assert_eq!(result["pictures"], 1);
let slide = s.doc().unwrap().doc.slides.first().unwrap();
let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_113)).unwrap();
let ShapeKind::Group { children, .. } = &picture.kind else { panic!("outer group expected") };
let ShapeKind::Group { children, .. } = &children[0].kind else { panic!("nested group expected") };
let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture expected") };
let media = s.doc().unwrap().doc.media(fill.media).unwrap();
let image = image::load_from_memory(&media.data).unwrap();
assert_eq!((image.width(), image.height()), (960, 480));
}
#[test]
fn ungrouped_picture_still_compresses_and_deletes_its_crop() {
let mut s = Session::with_new();
let original = png(800, 800);
s.edit(|doc, _| {
let media = doc.add_media("cropped.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
Arc::make_mut(slide).shapes.push(Shape {
id: ShapeId(70_104),
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
kind: ShapeKind::Picture { fill: PictureFill { media, crop: [0.5, 0.0, 0.0, 0.0], ..Default::default() } },
..Default::default()
});
Ok(())
})
.unwrap();
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": true})).unwrap();
assert_eq!(result["pictures"], 1);
let slide = s.doc().unwrap().doc.slides.first().unwrap();
let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_104)).unwrap();
let ShapeKind::Picture { fill } = &picture.kind else { panic!("picture expected") };
assert_eq!(fill.crop, [0.0; 4]);
let media = s.doc().unwrap().doc.media(fill.media).unwrap();
let image = image::load_from_memory(&media.data).unwrap();
assert_eq!((image.width(), image.height()), (96, 192));
}
#[test]
fn pack_deletes_cropped_areas_or_keeps_them() {
let src = png(400, 400);
+3 -3
View File
@@ -1,17 +1,17 @@
use super::{
Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json,
master_index, ok, opt_usize_param, rect_param, str_param, usize_param,
master_index, ok, opt_usize_param, rect_param, str_param,
};
pub(super) fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection));
let mi = opt_usize_param(p, "master", "view.slideMaster")?.unwrap_or_else(|| master_index(&st.doc, &st.selection));
// A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects
// that layout. With neither key, follow the current slide's layout.
let layout = if p.get("master").is_some() && p.get("layout").is_none() {
None
} else {
usize_param(p, "layout")
opt_usize_param(p, "layout", "view.slideMaster")?
.or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout))))
};
let target = match layout {
@@ -1,12 +1,14 @@
use super::{
Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value,
f64_param, ok, usize_param, with_param,
f64_param, ok, opt_usize_param, usize_param, with_param,
};
pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
if bool_or(p, "copy", false) {
return copy_slide_background(s);
}
// Check the index before anything is edited: a picture is added to the media first.
let index = opt_usize_param(p, "index", "design.background")?;
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
let transparency = f64_param(p, "transparency");
let reset = bool_or(p, "reset", false);
@@ -62,7 +64,6 @@ pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
));
};
let all = bool_or(p, "all", false);
let index = usize_param(p, "index");
if matches!(op, BgOp::Adjust) {
let next = {
let st = s.doc()?;
+13
View File
@@ -485,3 +485,16 @@ fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
}
#[test]
fn background_rejects_invalid_index() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({})).unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
let before = s.doc().unwrap().doc.slides[0].background.clone();
for bad in [json!(-1), json!(1.5)] {
assert!(s.execute("design.background", &json!({"color": "#0000C8", "index": bad})).is_err());
assert!(s.execute("design.background", &json!({"transparency": 0.5, "index": bad})).is_err());
assert_eq!(s.doc().unwrap().doc.slides[0].background, before);
}
}
+20 -2
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()),
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
"gif" => gif_bytes(doc, &Value::Null),
"png" | "jpeg" | "jpg" => Err(EngineError::Other("saving a presentation as a single image isn't supported; use file.export".into())),
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
}
}
@@ -359,7 +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() };
o.layout = match str_param(p, "layout").unwrap_or("slides") {
"notes" => deckcraft_pdf::PageLayout::Notes,
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
"handouts" => {
let per_page = opt_usize_param(p, "perPage", "file.export")?.unwrap_or(6);
if ![1, 2, 3, 4, 6, 9].contains(&per_page) {
return Err(bad("file.export", format!("perPage {per_page} isn't one of 1, 2, 3, 4, 6, 9")));
}
deckcraft_pdf::PageLayout::Handouts { per_page: per_page as u8 }
}
_ => deckcraft_pdf::PageLayout::Slides,
};
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
@@ -390,6 +397,7 @@ pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions>
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
let mut out: Vec<usize> = vec![];
let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
let (a, b) = match part.split_once('-') {
Some((a, b)) => (a.trim(), b.trim()),
@@ -405,7 +413,7 @@ pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
}
for i in lo - 1..hi {
if !out.contains(&i) {
if seen.insert(i) {
out.push(i);
}
}
@@ -769,6 +777,16 @@ mod tests {
use super::{parse_slide_range, write_file};
#[test]
fn broad_and_repeated_slide_ranges_expand_once() {
// 200 copies of a 100,000-slide range: each index once, in order, without a scan per index.
let text = vec!["1-100000"; 200].join(",");
let v = parse_slide_range(&text, 100_000).unwrap();
assert_eq!(v.len(), 100_000);
assert!(v.windows(2).all(|w| w[0] < w[1]));
assert_eq!(parse_slide_range("3, 1-3, 2", 3).unwrap(), vec![2, 0, 1]);
}
#[test]
fn slide_range_rejects_plus_sign() {
assert!(parse_slide_range("+2", 3).is_err());
+6 -1
View File
@@ -14,6 +14,7 @@ pub(super) fn align(s: &mut Session, p: &Value) -> Result<Value> {
"justify" | "just" => Align::Justify,
"justifyLow" | "justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
"thaiDist" => Align::ThaiDistributed,
_ => Align::Left,
};
align_to(s, a)
@@ -55,6 +56,7 @@ pub(super) fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
}
if size.is_some() {
para.props.bullet_size = size;
para.props.bullet_size_points = None;
para.props.bullet_size_follow_text = None;
}
if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
@@ -85,6 +87,7 @@ pub(super) fn numbering(s: &mut Session, p: &Value) -> Result<Value> {
}
if size.is_some() {
para.props.bullet_size = size;
para.props.bullet_size_points = None;
para.props.bullet_size_follow_text = None;
}
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
@@ -184,7 +187,8 @@ fn parse_align(cmd: &str, s: &str) -> Result<Align> {
"justify" | "just" => Align::Justify,
"justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")),
"thaiDist" => Align::ThaiDistributed,
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
})
}
@@ -196,6 +200,7 @@ fn align_name(a: Align) -> &'static str {
Align::Justify => "justify",
Align::JustLow => "justLow",
Align::Distributed => "distributed",
Align::ThaiDistributed => "thaiDist",
}
}
+42 -1
View File
@@ -246,8 +246,17 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
list.insert(at + k, sh);
}
list.retain(|x| !ids.contains(&x.id));
sel.shapes = new_ids.clone();
let _ = list;
if sel.target == crate::Target::Slides
&& let Some(sl) = doc.slides.get_mut(sel.slide)
{
let sl = std::sync::Arc::make_mut(sl);
sl.animations.retain(|a| !ids.contains(&a.shape));
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| ids.contains(&t))) {
a.trigger = None;
}
}
sel.shapes = new_ids.clone();
Ok(())
})?;
Ok(json!({"ids": new_ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
@@ -338,4 +347,36 @@ mod tests {
assert!(matches!(out.kind, ShapeKind::Shape) && out.text.is_none());
assert!(matches!(out.fill, Some(deckcraft_model::style::Fill::Solid { .. })), "{:?}", out.fill);
}
#[test]
fn merge_cleans_animation_targets_and_triggers_for_every_operation_and_undo_restores_them() {
for op in ["union", "combine", "fragment", "intersect", "subtract"] {
let mut s = Session::with_new();
let first = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 100, 100]})).expect("first shape")["id"]
.as_u64()
.expect("id") as u32;
let second = s.execute("shape.insert", &json!({"preset": "rect", "rect": [50, 0, 100, 100]})).expect("second shape")["id"]
.as_u64()
.expect("id") as u32;
let unrelated = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 0, 100, 100]})).expect("unrelated shape")["id"]
.as_u64()
.expect("id") as u32;
s.execute("animation.add", &json!({"effect": "fade", "ids": [second]})).expect("operand animation");
s.execute("animation.add", &json!({"effect": "spin", "ids": [unrelated]})).expect("unrelated animation");
s.execute("animation.timing", &json!({"index": 1, "trigger": first})).expect("trigger animation");
s.execute("shape.merge", &json!({"op": op, "ids": [first, second]})).expect("merge");
let animations = &s.doc().expect("doc").doc.slides[0].animations;
assert_eq!(animations.len(), 1, "{op} removed animations for merged operands");
assert_eq!(animations[0].shape.0, unrelated, "{op} preserved the unrelated effect");
assert_eq!(animations[0].trigger, None, "{op} cleared the removed shape trigger");
s.execute("edit.undo", &json!({})).expect("undo merge");
let animations = &s.doc().expect("doc").doc.slides[0].animations;
assert_eq!(animations.len(), 2, "{op} undo restored removed effects");
assert_eq!(animations[0].shape.0, second);
assert_eq!(animations[1].shape.0, unrelated);
assert_eq!(animations[1].trigger.map(|id| id.0), Some(first));
}
}
}
+8 -1
View File
@@ -135,7 +135,14 @@ pub fn apply(sh: &mut Shape, id: &str) -> bool {
let e = Effects {
outer_shadow: lk.shadow.map(shadow),
soft_edge: lk.soft_edge,
reflection: lk.reflection.then_some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
reflection: lk.reflection.then_some(Reflection {
blur: 0.5,
start_alpha: 0.5,
end_alpha: 0.0,
end_pos: 0.35,
dist: 0.0,
..Default::default()
}),
bevel: lk.bevel.map(str::to_string),
..Default::default()
};
+437 -4
View File
@@ -6,16 +6,155 @@
//! renumbered with animations, triggers and connector ends following them. Links to another
//! reused slide follow its copy.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
use deckcraft_model::table::TableStyleDef;
use deckcraft_model::text::{Action, Hyperlink, ListStyle, RunProps, Spacing, TextBody};
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use serde_json::{Value, json};
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
use crate::{Result, Session, media_refs};
/// Scale physical measurements in imported content to the destination slide's coordinate space.
/// Text has one size, so it uses the geometric mean of the horizontal and vertical scale.
fn scale_text(t: &mut TextBody, sx: f64, sy: f64) {
let unit = (sx * sy).sqrt();
for v in [&mut t.body.inset_l, &mut t.body.inset_r, &mut t.body.col_spacing].into_iter().flatten() {
*v *= sx;
}
for v in [&mut t.body.inset_t, &mut t.body.inset_b].into_iter().flatten() {
*v *= sy;
}
scale_list(&mut t.list_style, sx, unit);
for p in &mut t.paragraphs {
for v in [&mut p.props.margin_left, &mut p.props.margin_right, &mut p.props.indent, &mut p.props.default_tab].into_iter().flatten() {
*v *= sx;
}
if let Some(stops) = &mut p.props.tabs {
for stop in stops {
stop.pos *= sx;
}
}
for spacing in [&mut p.props.line_spacing, &mut p.props.space_before, &mut p.props.space_after] {
if let Some(Spacing::Pts(v)) = spacing {
*v *= unit;
}
}
if let Some(v) = &mut p.props.bullet_size_points {
*v *= unit;
}
scale_run(&mut p.def_props, unit);
scale_run(&mut p.end_props, unit);
for r in &mut p.runs {
scale_run(&mut r.props, unit);
}
}
}
fn scale_list(list: &mut ListStyle, sx: f64, unit: f64) {
for level in list.levels.iter_mut().flatten() {
for v in
[&mut level.para.margin_left, &mut level.para.margin_right, &mut level.para.indent, &mut level.para.default_tab].into_iter().flatten()
{
*v *= sx;
}
for spacing in [&mut level.para.line_spacing, &mut level.para.space_before, &mut level.para.space_after] {
if let Some(Spacing::Pts(v)) = spacing {
*v *= unit;
}
}
if let Some(v) = &mut level.para.bullet_size_points {
*v *= unit;
}
scale_run(&mut level.run, unit);
}
}
fn scale_run(run: &mut RunProps, factor: f64) {
for v in [&mut run.size, &mut run.spacing, &mut run.kern, &mut run.soft_edge].into_iter().flatten() {
*v *= factor;
}
}
fn scale_shapes(shapes: &mut [deckcraft_model::Shape], sx: f64, sy: f64, depth: usize) {
if depth > 64 {
return;
}
let unit = (sx * sy).sqrt();
for shape in shapes {
if let Some(x) = &mut shape.xfrm {
x.x *= sx;
x.w *= sx;
x.y *= sy;
x.h *= sy;
}
if let Some(text) = &mut shape.text {
scale_text(text, sx, sy);
}
match &mut shape.kind {
ShapeKind::Table(table) => {
for col in &mut table.cols {
*col *= sx;
}
for row in &mut table.rows {
row.height *= sy;
for cell in &mut row.cells {
if let Some(margins) = &mut cell.margins {
if let Some(v) = margins.get_mut(0) {
*v *= sx;
}
if let Some(v) = margins.get_mut(1) {
*v *= sx;
}
if let Some(v) = margins.get_mut(2) {
*v *= sy;
}
if let Some(v) = margins.get_mut(3) {
*v *= sy;
}
}
scale_text(&mut cell.text, sx, sy);
}
}
}
ShapeKind::Group { children, child } => {
child.x *= sx;
child.w *= sx;
child.y *= sy;
child.h *= sy;
scale_shapes(children, sx, sy, depth + 1);
}
_ => {}
}
if let Some(effects) = &mut shape.effects {
for shadow in [&mut effects.outer_shadow, &mut effects.inner_shadow].into_iter().flatten() {
shadow.blur *= unit;
shadow.dist *= unit;
}
if let Some(glow) = &mut effects.glow {
glow.radius *= unit;
}
if let Some(reflection) = &mut effects.reflection {
reflection.blur *= unit;
reflection.dist *= unit;
}
}
}
}
fn scale_master(master: &mut Master, sx: f64, sy: f64) {
scale_shapes(&mut master.shapes, sx, sy, 0);
let unit = (sx * sy).sqrt();
scale_list(&mut master.title_style, sx, unit);
scale_list(&mut master.body_style, sx, unit);
scale_list(&mut master.other_style, sx, unit);
for layout in &mut master.layouts {
scale_shapes(&mut layout.shapes, sx, sy, 0);
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -152,6 +291,90 @@ fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::Layout
.unwrap_or_default()
}
fn collect_table_styles(shapes: &[deckcraft_model::Shape], styles: &mut Vec<String>, seen: &mut HashSet<String>, depth: usize) {
if depth > 64 {
return;
}
for shape in shapes {
if let ShapeKind::Table(table) = &shape.kind
&& seen.insert(table.style.to_ascii_lowercase())
{
styles.push(table.style.clone());
}
collect_table_styles(shape.children(), styles, seen, depth + 1);
}
}
fn remap_table_styles(shapes: &mut [deckcraft_model::Shape], styles: &HashMap<String, String>, depth: usize) {
if depth > 64 {
return;
}
for shape in shapes {
if let ShapeKind::Table(table) = &mut shape.kind
&& let Some(style) = styles.get(&table.style.to_ascii_lowercase())
{
table.style = style.clone();
}
if let Some(children) = shape.children_mut() {
remap_table_styles(children, styles, depth + 1);
}
}
}
fn same_table_style(a: &TableStyleDef, b: &TableStyleDef) -> bool {
a.name == b.name && a.parts == b.parts && a.background == b.background && a.raw == b.raw
}
/// Copy only definitions used on selected slides. Conflicting GUIDs receive a fresh GUID so
/// existing destination tables continue to resolve to their original definitions.
fn import_table_styles(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[String]) -> Result<HashMap<String, String>> {
let mut map = HashMap::new();
let mut source_defs = HashMap::new();
let mut source_ids = HashSet::new();
for (index, style) in src.table_styles.iter().enumerate() {
let key = style.id.to_ascii_lowercase();
source_defs.entry(key.clone()).or_insert(index);
source_ids.insert(key);
}
let mut destination_defs = HashMap::new();
for (index, style) in doc.table_styles.iter().enumerate() {
destination_defs.entry(style.id.to_ascii_lowercase()).or_insert(index);
}
for id in used {
let key = id.to_ascii_lowercase();
if map.contains_key(&key) {
continue;
}
let Some(source_index) = source_defs.get(&key).copied() else { continue };
let Some(source) = src.table_styles.get(source_index) else { continue };
let destination = destination_defs.get(&key).and_then(|index| doc.table_styles.get(*index));
let mut imported = source.clone();
if let Some(existing) = destination {
if same_table_style(existing, source) {
map.insert(key, existing.id.clone());
continue;
}
let candidate = loop {
let n = doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?;
let candidate = format!("{{00000000-0000-4000-8000-{n:012X}}}");
let key = candidate.to_ascii_lowercase();
if !destination_defs.contains_key(&key) && !source_ids.contains(&key) {
break candidate;
}
};
// The definition's own XML names it too, and PPTX export writes that XML as is.
imported.raw = imported.raw.replacen(&format!("styleId=\"{}\"", source.id), &format!("styleId=\"{candidate}\""), 1);
imported.id.clone_from(&candidate);
map.insert(key, candidate);
} else {
map.insert(key, imported.id.clone());
}
destination_defs.insert(imported.id.to_ascii_lowercase(), doc.table_styles.len());
doc.table_styles.push(imported);
}
Ok(map)
}
fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes) = super::insert::media_bytes(p, "slide.reuse")?;
let src = super::file::open_presentation(&name, &bytes)?;
@@ -176,8 +399,23 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
}
let keep = bool_or(p, "keepFormatting", false);
let at = usize_param(p, "at");
let source_size = src.slide_size;
s.edit(|doc, sel| {
let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len());
let mut used_table_styles = Vec::new();
let mut seen_table_styles = HashSet::new();
for &i in &picked {
if let Some(slide) = src.slides.get(i) {
collect_table_styles(&slide.shapes, &mut used_table_styles, &mut seen_table_styles, 0);
}
}
let table_style_map = import_table_styles("slide.reuse", doc, &src, &used_table_styles)?;
let scale = |source: f64, destination: f64| {
let ratio = destination / source;
if source > 0.0 && destination > 0.0 && ratio.is_finite() && ratio > 0.0 { ratio } else { 1.0 }
};
let sx = scale(source_size.width, doc.slide_size.width);
let sy = scale(source_size.height, doc.slide_size.height);
// Media: what the slides use is copied under fresh ids.
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
// Keep Source Formatting: bring each source master used, once, with fresh ids.
@@ -190,7 +428,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
masters_done.push(m.id);
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
let mut m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
scale_master(&mut m, sx, sy);
doc.masters.push(Arc::new(m));
}
}
@@ -201,6 +440,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
let Some(from) = src.slides.get(i) else { continue };
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
let mut sl: Slide = from.as_ref().clone();
remap_table_styles(&mut sl.shapes, &table_style_map, 0);
scale_shapes(&mut sl.shapes, sx, sy, 0);
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
@@ -284,13 +525,205 @@ fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[Med
#[cfg(test)]
mod tests {
use deckcraft_geom::{Size, Xfrm};
use deckcraft_model::style::{Artistic, Fill, PictureFill};
use deckcraft_model::table::TableStyleDef;
use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody};
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults};
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, Table, defaults};
use serde_json::json;
use super::*;
const CUSTOM_STYLE: &str = "{11111111-2222-4333-8444-555555555555}";
fn styled_table_presentation(id: &str, color: &str) -> Presentation {
let mut p = deckcraft_model::defaults::new_presentation(None);
let raw = format!(
r#"<a:tblStyle xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" styleId="{id}" styleName="Owned"><a:wholeTbl><a:tcStyle><a:fill><a:solidFill><a:srgbClr val="{color}"/></a:solidFill></a:fill></a:tcStyle></a:wholeTbl></a:tblStyle>"#
);
p.table_styles.push(TableStyleDef { id: id.into(), name: "Owned".into(), raw, ..Default::default() });
let table = deckcraft_model::Table { style: id.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
let child = deckcraft_model::Shape { id: ShapeId(p.alloc_id().expect("shape id")), kind: ShapeKind::Table(table), ..Default::default() };
let group = deckcraft_model::Shape {
id: ShapeId(p.alloc_id().expect("group id")),
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
..Default::default()
};
let slide = p.slides.first_mut().expect("default slide");
Arc::make_mut(slide).shapes.push(group);
p
}
fn first_table_style(shapes: &[deckcraft_model::Shape]) -> Option<String> {
for shape in shapes {
if let ShapeKind::Table(table) = &shape.kind {
return Some(table.style.clone());
}
if let Some(style) = first_table_style(shape.children()) {
return Some(style);
}
}
None
}
#[test]
fn reused_grouped_table_styles_survive_native_and_pptx_round_trips_and_undo() {
for (name, bytes) in [
("source.deckcraft", deckcraft_format::save(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("native source")),
("source.pptx", deckcraft_pptx::export(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("pptx source")),
] {
let data = super::super::base64_encode(&bytes);
let mut session = Session::with_new();
session.execute("slide.reuse", &json!({"data": data, "name": name, "slides": [0], "keepFormatting": true})).expect("reuse");
let doc = &session.doc().expect("doc").doc;
assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("FF0000")));
assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
session.execute("edit.undo", &json!({})).expect("undo reuse");
let doc = &session.doc().expect("doc").doc;
assert!(doc.table_styles.iter().all(|style| style.id != CUSTOM_STYLE));
session.execute("edit.redo", &json!({})).expect("redo reuse");
let bytes = crate::cmd::file::save_bytes(&session.doc().expect("doc").doc, "deckcraft").expect("save destination");
let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &bytes).expect("reopen destination");
assert!(reopened.table_styles.iter().any(|style| style.id == CUSTOM_STYLE));
assert_eq!(first_table_style(&reopened.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
}
}
#[test]
fn reused_table_style_definitions_are_deduplicated_or_remapped_on_collision() {
let source = styled_table_presentation(CUSTOM_STYLE, "FF0000");
let source_bytes = deckcraft_format::save(&source).expect("save source");
let mut identical = Session::with_new();
identical
.edit(|doc, _| {
doc.table_styles.push(source.table_styles.first().cloned().unwrap_or_default());
Ok(())
})
.expect("seed identical definition");
identical
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
.expect("reuse identical definition");
let doc = &identical.doc().expect("doc").doc;
assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
let mut conflicting = Session::with_new();
conflicting
.edit(|doc, _| {
let mut own = source.table_styles.first().cloned().unwrap_or_default();
own.raw = own.raw.replace("FF0000", "0000FF");
doc.table_styles.push(own);
let table = deckcraft_model::Table { style: CUSTOM_STYLE.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
let id = ShapeId(doc.alloc_id().expect("destination table id"));
if let Some(slide) = doc.slides.first_mut() {
Arc::make_mut(slide).shapes.push(Shape { id, kind: ShapeKind::Table(table), ..Default::default() });
}
Ok(())
})
.expect("seed conflicting definition");
conflicting
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
.expect("reuse conflicting definition");
let doc = &conflicting.doc().expect("doc").doc;
assert_eq!(first_table_style(&doc.slides.first().expect("destination slide").shapes).as_deref(), Some(CUSTOM_STYLE));
let imported_style = first_table_style(&doc.slides.get(1).expect("reused slide").shapes).expect("grouped table style");
assert_ne!(imported_style, CUSTOM_STYLE);
assert!(
doc.table_styles.iter().any(|style| style.id == imported_style
&& style.raw.contains("FF0000")
&& style.raw.contains(&format!("styleId=\"{imported_style}\"")))
);
assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("0000FF")));
}
#[test]
fn reused_slide_content_fits_destination_size() {
let mut src = defaults::new_presentation(None);
src.slide_size = Size::new(960.0, 540.0);
let slide_id = src.slides.first().map(|s| s.id).unwrap_or_default();
if let Some(slide) = src.slides.first_mut().map(Arc::make_mut) {
let mut text = TextBody::from_text("scaled");
if let Some(run) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
run.props.size = Some(24.0);
}
slide.shapes = vec![
Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 200.0, 100.0)), ..Default::default() },
Shape {
xfrm: Some(Xfrm::new(40.0, 50.0, 400.0, 200.0)),
text: Some(text),
kind: ShapeKind::Table({
let mut table = Table::new(2, 2, 400.0, 40.0);
table.rows[0].cells[0].text = TextBody::from_text("cell");
if let Some(run) = table.rows[0].cells[0].text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
run.props.size = Some(18.0);
}
table
}),
..Default::default()
},
Shape {
xfrm: Some(Xfrm::new(100.0, 80.0, 300.0, 150.0)),
kind: ShapeKind::Group {
child: Xfrm::new(0.0, 0.0, 300.0, 150.0),
children: vec![Shape { xfrm: Some(Xfrm::new(200.0, 100.0, 80.0, 40.0)), ..Default::default() }],
},
..Default::default()
},
];
}
if let Some(master) = src.masters.first_mut().map(Arc::make_mut) {
master.shapes = vec![Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 100.0, 50.0)), ..Default::default() }];
if let Some(layout) = master.layouts.first_mut() {
layout.shapes = vec![Shape { xfrm: Some(Xfrm::new(700.0, 300.0, 100.0, 50.0)), ..Default::default() }];
}
}
let bytes = deckcraft_format::save(&src).expect("source save");
let data = super::super::base64_encode(&bytes);
let mut session = Session::with_new();
session
.edit(|doc, _| {
doc.slide_size = Size::new(480.0, 270.0);
Ok(())
})
.expect("destination size");
session.execute("slide.reuse", &json!({"data": data, "name": "source.deckcraft", "slides": [0], "keepFormatting": true})).expect("reuse");
let doc = &session.doc().expect("doc").doc;
let reused = doc.slides.get(1).expect("reused slide");
let first = reused.shapes.first().and_then(|s| s.xfrm).expect("shape transform");
assert_eq!(first, Xfrm::new(400.0, 200.0, 100.0, 50.0));
let table_shape = reused.shapes.get(1).expect("table");
let table_box = table_shape.xfrm.expect("table transform");
assert_eq!(table_box, Xfrm::new(20.0, 25.0, 200.0, 100.0));
assert!(matches!(&table_shape.kind, ShapeKind::Table(_)));
let ShapeKind::Table(table) = &table_shape.kind else { return };
assert_eq!(table.cols, vec![100.0, 100.0]);
assert_eq!(table.rows.first().map(|r| r.height), Some(20.0));
let font_size = table_shape.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).and_then(|r| r.props.size);
assert_eq!(font_size, Some(12.0));
let cell_size = table.rows[0].cells[0].text.paragraphs.first().and_then(|p| p.runs.first()).and_then(|r| r.props.size);
assert_eq!(cell_size, Some(9.0));
let group = reused.shapes.get(2).expect("group");
assert!(matches!(&group.kind, ShapeKind::Group { .. }));
let ShapeKind::Group { children, child } = &group.kind else { return };
assert_eq!(group.xfrm, Some(Xfrm::new(50.0, 40.0, 150.0, 75.0)));
assert_eq!(*child, Xfrm::new(0.0, 0.0, 150.0, 75.0));
assert_eq!(children.first().and_then(|s| s.xfrm), Some(Xfrm::new(100.0, 50.0, 40.0, 20.0)));
let imported_master = doc.masters.last().expect("imported master");
assert_eq!(imported_master.shapes.first().and_then(|s| s.xfrm), Some(Xfrm::new(400.0, 200.0, 50.0, 25.0)));
assert_eq!(imported_master.layouts.first().and_then(|l| l.shapes.first()).and_then(|s| s.xfrm), Some(Xfrm::new(350.0, 150.0, 50.0, 25.0)));
assert_ne!(reused.id, slide_id);
let saved = deckcraft_format::save(&doc.clone()).expect("destination save");
let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &saved).expect("destination reopen");
let reopened_shape = reopened.slides.get(1).and_then(|s| s.shapes.first()).and_then(|s| s.xfrm);
assert_eq!(reopened_shape, Some(Xfrm::new(400.0, 200.0, 100.0, 50.0)));
}
/// Every kind of media a slide can hold comes along, each pointing at its own bytes: the
/// artistic-effect original, a background picture, poster, sounds and picture bullets too.
#[test]
+56 -6
View File
@@ -367,7 +367,7 @@ fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
}
out.push(v);
}
let rep: Vec<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
let rep: std::collections::HashSet<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
for w in crate::spell::paragraph_words(para, &default) {
if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) {
continue;
@@ -421,23 +421,30 @@ fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result<Value> {
/// Replace whole-word `from` with `to` in `text`; how many were replaced.
fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize {
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
let mut n = 0;
let mut at = 0;
// The result is built in one pass; the text is not edited in place for every match.
let mut out = String::new();
let (mut n, mut at, mut copied) = (0, 0, 0);
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
let end = i + from.len();
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
// The character before the match as the edited text would have it.
let before = text.get(copied..i).and_then(|t| t.chars().next_back()).or_else(|| out.chars().next_back()).is_some_and(is_word);
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
if before || after {
at = end;
continue;
}
text.replace_range(i..end, to);
out.push_str(text.get(copied..i).unwrap_or_default());
out.push_str(to);
n += 1;
at = i + to.len();
(at, copied) = (end, end);
if !all {
break;
}
}
if n > 0 {
out.push_str(text.get(copied..).unwrap_or_default());
*text = out;
}
n
}
@@ -764,6 +771,49 @@ fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
/// The replacement as it ran before: edited in place for every match.
fn replace_word_in_place(text: &mut String, from: &str, to: &str, all: bool) -> usize {
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
let mut n = 0;
let mut at = 0;
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
let end = i + from.len();
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
if before || after {
at = end;
continue;
}
text.replace_range(i..end, to);
n += 1;
at = i + to.len();
if !all {
break;
}
}
n
}
#[test]
fn replace_word_matches_the_in_place_version() {
let cases = [
("a a b a", "a", "x", true),
("aa a. a", "a", "bb", true),
("a-a -a-", "-", "ab", true),
("foo. foo.", "foo.", "", true),
("it's it", "it", "so", true),
("x.x. x.", "x.", "y", true),
("a a a", "a", "b", false),
("none", "z", "q", true),
("é é", "é", "e", true),
];
for (text, from, to, all) in cases {
let (mut a, mut b) = (text.to_string(), text.to_string());
let (na, nb) = (replace_word(&mut a, from, to, all), replace_word_in_place(&mut b, from, to, all));
assert_eq!((na, a), (nb, b), "{text:?} {from:?} -> {to:?}");
}
}
use super::*;
use serde_json::json;
+1 -1
View File
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Fill",
["Shape Format", "Shape Styles"],
None,
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
has_selection,
fill
),
+11 -4
View File
@@ -371,9 +371,9 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0)));
let refl = str_param(p, "reflection").map(|r| match r {
"none" => None,
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }),
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }),
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0, ..Default::default() }),
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0, ..Default::default() }),
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0, ..Default::default() }),
});
let three_d = p.get("threeD").filter(|v| v.is_object());
let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) });
@@ -467,7 +467,14 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
e.glow = (g.radius > 0.0).then_some(g);
}
if let Some(v) = &refl_props {
let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 });
let mut r = e.reflection.take().unwrap_or(Reflection {
blur: 0.5,
start_alpha: 0.5,
end_alpha: 0.0,
end_pos: 0.35,
dist: 0.0,
..Default::default()
});
if let Some(t) = num(v, "transparency") {
r.start_alpha = 1.0 - t.clamp(0.0, 1.0);
}
+44 -1
View File
@@ -267,6 +267,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
for sec in &mut doc.sections {
sec.slides.retain(|x| !ids.contains(x));
}
for show in &mut doc.custom_shows {
show.slides.retain(|x| !ids.contains(x));
}
sel.slide = first.min(doc.slides.len().saturating_sub(1));
sel.slides = doc.slides.get(sel.slide).map(|x| vec![x.id]).unwrap_or_default();
sel.shapes.clear();
@@ -405,8 +408,9 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
};
s.select(|doc, sel| {
if add {
let mut have: std::collections::HashSet<_> = sel.slides.iter().copied().collect();
for i in &ids {
if !sel.slides.contains(i) {
if have.insert(*i) {
sel.slides.push(*i);
}
}
@@ -777,6 +781,45 @@ mod tests {
(sel.slide, sel.slides.iter().map(|x| x.0).collect())
}
#[test]
fn deleting_slide_prunes_every_custom_show_in_one_undo_step() {
let (mut s, ids) = deck(3);
let shows = vec![
deckcraft_model::CustomShow {
name: "First".into(),
slides: vec![deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])],
},
deckcraft_model::CustomShow { name: "Second".into(), slides: vec![deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])] },
deckcraft_model::CustomShow { name: "Empty after delete".into(), slides: vec![deckcraft_model::SlideId(ids[1])] },
];
s.edit(|doc, _| {
doc.custom_shows = shows;
Ok(())
})
.expect("set custom shows");
s.execute("slide.delete", &json!({"index": 1})).expect("delete slide");
let shows = &s.doc().expect("doc").doc.custom_shows;
assert_eq!(shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
assert_eq!(shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
assert!(shows[2].slides.is_empty());
s.execute("edit.undo", &json!({})).expect("undo deletion");
assert_eq!(order(&s), ids);
assert_eq!(
s.doc().expect("doc").doc.custom_shows[0].slides,
[deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])]
);
assert_eq!(s.doc().expect("doc").doc.custom_shows[1].slides, [deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])]);
s.execute("edit.redo", &json!({})).expect("redo deletion");
let bytes = deckcraft_format::save(&s.doc().expect("doc").doc).expect("save deck");
let reopened = deckcraft_format::load(&bytes).expect("reopen deck");
assert_eq!(reopened.custom_shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
assert_eq!(reopened.custom_shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
assert!(reopened.custom_shows[2].slides.is_empty());
}
#[test]
fn move_from_to_moves_one_slide() {
let (mut s, ids) = deck(4);
+112 -18
View File
@@ -121,9 +121,10 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect();
let rev_matched: HashSet<usize> = pairs.iter().map(|p| p.1).collect();
let mut out = vec![];
let mut media = Media::new(cur, rev);
for (i, j) in &pairs {
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
let details = slide_details(cur, a, rev, b);
let details = slide_details(cur, a, rev, b, &mut media);
if !details.is_empty() {
out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details });
}
@@ -140,10 +141,18 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
});
}
}
// Each revised slide's open match (the first pair naming it).
let mut rev_to_cur: HashMap<usize, usize> = HashMap::new();
for p in &pairs {
rev_to_cur.entry(p.1).or_insert(p.0);
}
// After the open slide that matches the nearest matched revised slide before it.
let mut at = 0;
for (j, s) in rev.slides.iter().enumerate() {
if let Some(i) = rev_to_cur.get(&j) {
at = i + 1;
}
if !rev_matched.contains(&j) {
// After the open slide that matches the revised slide before it.
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
out.push(Change {
kind: ChangeKind::Inserted,
slide: at,
@@ -174,8 +183,34 @@ fn shape_label(s: &Shape) -> String {
if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() }
}
fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> {
p.media(id).map(|m| m.data.as_slice())
/// Both decks' media bytes by id, and which picture pairs were already compared: a deck can
/// show one picture many times, and its bytes are compared once per pair.
struct Media<'a> {
cur: HashMap<MediaId, &'a [u8]>,
rev: HashMap<MediaId, &'a [u8]>,
same: HashMap<(MediaId, MediaId), bool>,
}
impl<'a> Media<'a> {
fn new(cur: &'a Presentation, rev: &'a Presentation) -> Self {
// The first item with an id wins, as in `Presentation::media`.
let index = |p: &'a Presentation| {
let mut m = HashMap::new();
for x in &p.media {
m.entry(x.id).or_insert(x.data.as_slice());
}
m
};
Media { cur: index(cur), rev: index(rev), same: HashMap::new() }
}
/// Whether the pictures (by media id, `None` for a shape that is not a picture) differ.
fn differ(&mut self, a: Option<MediaId>, b: Option<MediaId>) -> bool {
let (ba, bb) = (a.and_then(|x| self.cur.get(&x)), b.and_then(|y| self.rev.get(&y)));
match (a, b) {
(Some(x), Some(y)) if ba.is_some() && bb.is_some() => !*self.same.entry((x, y)).or_insert_with(|| ba == bb),
_ => ba.is_some() || bb.is_some(),
}
}
}
/// A shape without its ids (shape and media), for comparing across decks.
@@ -204,7 +239,7 @@ fn turn(ctx: Option<&Ctx>, s: &Shape) -> (f64, bool, bool) {
drawn_box(ctx, s).map_or((0.0, false, false), |x| (x.rot, x.flip_h, x.flip_v))
}
fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentation, cb: Option<&Ctx>, b: &Shape, out: &mut Vec<String>) {
fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media: &mut Media, out: &mut Vec<String>) {
let label = shape_label(b);
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
let mut what = vec![];
@@ -234,11 +269,11 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
what.push("formatting");
}
let pic = |p: &Presentation, s: &Shape| match &s.kind {
ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()),
let pic = |s: &Shape| match &s.kind {
ShapeKind::Picture { fill } => Some(fill.media),
_ => None,
};
if pic(cp, a) != pic(rp, b) {
if media.differ(pic(a), pic(b)) {
what.push("picture");
}
// Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also
@@ -265,21 +300,32 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
}
}
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> {
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide, media: &mut Media) -> Vec<String> {
let mut out = vec![];
let mut used: HashSet<usize> = HashSet::new();
let (ca, cb) = (Ctx::for_slide(cp, a), Ctx::for_slide(rp, b));
// The revised shapes by id (first wins) and by name (in order, with a cursor past used ones).
let mut by_id: HashMap<ShapeId, usize> = HashMap::new();
let mut by_name: HashMap<&str, (Vec<usize>, usize)> = HashMap::new();
for (k, x) in b.shapes.iter().enumerate() {
by_id.entry(x.id).or_insert(k);
if !x.name.is_empty() {
by_name.entry(x.name.as_str()).or_default().0.push(k);
}
}
for sa in &a.shapes {
let k = b
.shapes
.iter()
.position(|x| x.id == sa.id)
.or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty()));
let k = by_id.get(&sa.id).copied().or_else(|| {
let (ks, next) = by_name.get_mut(sa.name.as_str())?;
while ks.get(*next).is_some_and(|k| used.contains(k)) {
*next += 1;
}
ks.get(*next).copied()
});
match k {
Some(k) if !used.contains(&k) => {
used.insert(k);
if let Some(sb) = b.shapes.get(k) {
shape_details(cp, ca.as_ref(), sa, rp, cb.as_ref(), sb, &mut out);
shape_details(ca.as_ref(), sa, cb.as_ref(), sb, media, &mut out);
}
}
_ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))),
@@ -334,19 +380,34 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
let mut sl = slide.clone();
// Media.
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
// Both decks' media by id (the first item with an id wins, as in `Presentation::media`),
// looked up once per reference instead of searched.
let mut rev_at: HashMap<MediaId, usize> = HashMap::new();
for (i, m) in rev.media.iter().enumerate() {
rev_at.entry(m.id).or_insert(i);
}
let mut doc_at: HashMap<MediaId, usize> = HashMap::new();
for (i, m) in doc.media.iter().enumerate() {
doc_at.entry(m.id).or_insert(i);
}
for id in crate::media_refs::slide_media(&sl) {
let Some(m) = rev.media(id) else { continue };
match doc.media(id) {
let Some(m) = rev_at.get(&id).and_then(|&i| rev.media.get(i)) else { continue };
match doc_at.get(&id).and_then(|&i| doc.media.get(i)) {
Some(x) if x.data == m.data => {}
None => {
// Keeps the revision's id, so later ids must start past it.
doc_at.insert(id, doc.media.len());
doc.media.push(m.clone());
doc.next_id = doc.next_id.max(id.0);
}
Some(_) => {
let before = doc.media.len();
let new = doc
.add_media(&m.name, &m.content_type, m.data.as_ref().clone())
.ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?;
if doc.media.len() > before {
doc_at.entry(new).or_insert(before);
}
media_map.insert(id, new);
}
}
@@ -519,6 +580,39 @@ mod tests {
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
}
#[test]
fn many_inserted_slides_and_same_named_pictures_compare_by_index() {
use std::sync::Arc;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{Shape, ShapeId, ShapeKind, SlideId, defaults};
// One slide of 3,000 pictures of one image, all named "Box"; the revision has the same
// pictures under other ids (matched by name, in order) and 3,000 slides inserted after it.
let mut cur = defaults::new_presentation(None);
let img = cur.add_media("a.png", "image/png", vec![7; 4096]).unwrap();
let mut rev = cur.clone();
let pic = |id: u32| Shape {
id: ShapeId(id),
name: "Box".into(),
xfrm: Some(deckcraft_model::Xfrm::new(10.0, 10.0, 50.0, 50.0)),
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
..Default::default()
};
Arc::make_mut(&mut cur.slides[0]).shapes = (0..3000).map(|k| pic(100_000 + k)).collect();
Arc::make_mut(&mut rev.slides[0]).shapes = (0..3000).map(|k| pic(200_000 + k)).collect();
let mut base = (*rev.slides[0]).clone();
base.shapes.clear();
for k in 0..3000 {
let mut s = base.clone();
s.id = SlideId(300_000 + k);
rev.slides.push(Arc::new(s));
}
let changes = super::diff(&cur, &rev, false);
assert_eq!(changes.len(), 3000, "only the inserted slides differ");
assert!(changes.iter().all(|c| c.kind == super::ChangeKind::Inserted && c.slide == 1));
}
/// A deck with one slide: a table whose cell has a picture fill, a picture background and a
/// transition sound. `tag` starts the bytes of each.
fn media_deck(tag: &str) -> deckcraft_model::Presentation {
+31 -2
View File
@@ -41,12 +41,15 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
Ok(canonical) => canonical,
Err(_) => std::path::PathBuf::from(path),
};
let target_str = target_path.to_str().unwrap_or(path);
let existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions());
let mut attempts = 0;
let (tmp, mut file) = loop {
let tmp = format!("{target_str}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
// A short fixed-size name: appending to the target's name could exceed the file name limit.
let tmp_name = format!(".deckcraft.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
let tmp = target_path.with_file_name(tmp_name);
// The sibling must pass the same check: when `path` is the write root itself it lands outside.
crate::file_access::check_write(&tmp.to_string_lossy())?;
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(f) => break (tmp, f),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
@@ -120,4 +123,30 @@ mod tests {
let _ = std::fs::remove_file(&test_file);
let _ = std::fs::remove_dir(&tmp_dir);
}
#[test]
fn write_file_overwrites_file_with_name_near_the_length_limit() {
let dir = std::env::temp_dir().join("deckcraft_io_long_name_test");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join(format!("{}.pptx", "a".repeat(250)));
std::fs::write(&file, b"old").unwrap();
let path_str = file.to_str().unwrap();
write_file(path_str, b"new").unwrap();
assert_eq!(read_file(path_str).unwrap(), b"new");
let _ = std::fs::remove_file(&file);
}
#[test]
fn write_file_to_the_write_root_itself_creates_no_sibling_outside_it() {
let base = std::env::temp_dir().join(format!("deckcraft_io_root_test_{}", std::process::id()));
let root = base.join("root");
std::fs::create_dir_all(&root).unwrap();
let roots = crate::file_access::AutomationRoots::new(&[], std::slice::from_ref(&root)).unwrap().unwrap();
let res = crate::file_access::confine(Some(&roots), || write_file(root.to_str().unwrap(), b"x"));
assert!(res.unwrap_err().contains("outside the write root"));
assert_eq!(std::fs::read_dir(&base).unwrap().count(), 1, "only the root is there");
let _ = std::fs::remove_dir_all(&base);
}
}
+10
View File
@@ -259,6 +259,16 @@ mod tests {
dir
}
#[test]
fn instance_identity_is_stable_nonzero_and_json_safe() {
let id = instance_id();
assert_eq!(instance_id(), id);
assert!(id != 0 && id <= UID_MASK);
assert_eq!(id & 0xFF_FFFF, 0);
let encoded = serde_json::to_value(id).unwrap();
assert_eq!(encoded.as_f64().unwrap() as u64, id);
}
#[test]
fn recovers_unsaved_presentations() {
let dir = temp("recovery");
+66 -11
View File
@@ -700,16 +700,31 @@ pub struct Word {
pub repeated: Option<usize>,
}
/// Longest language tag a word is checked in. BCP 47 tags in documents are a few bytes; a longer
/// value from a file is no language we have a dictionary for, and copying it into every word of
/// a paragraph would multiply it.
const MAX_LANG_LEN: usize = 64;
/// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words
/// with digits are skipped; apostrophes inside a word belong to it; hyphenated words are checked
/// part by part.
/// part by part. A language over [`MAX_LANG_LEN`] counts as none.
pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Word> {
let mut chars: Vec<(char, usize)> = vec![];
for (ri, r) in p.runs.iter().enumerate() {
chars.extend(r.content().chars().map(|c| (c, ri)));
}
let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string());
let inside = |c: char| c.is_alphanumeric() || c == '\'' || c == '’' || c == '-';
let default = if default.len() > MAX_LANG_LEN { "" } else { default };
let lang_of = |ri: usize| {
p.runs
.get(ri)
.and_then(|r| r.props.lang.as_deref())
.filter(|l| !l.is_empty())
.map(|l| if l.len() > MAX_LANG_LEN { "" } else { l })
.unwrap_or(default)
.to_string()
};
// Combining marks (decomposed accents) belong to the letter before them.
let inside = |c: char| c.is_alphanumeric() || deckcraft_model::words::is_combining_mark(c) || c == '\'' || c == '’' || c == '-';
let mut out = vec![];
let mut i = 0;
while i < chars.len() {
@@ -725,8 +740,14 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Wor
while a < b && !chars[a].0.is_alphanumeric() {
a += 1;
}
while b > a && !chars[b - 1].0.is_alphanumeric() {
b -= 1;
// Trim back to the last letter or digit, keeping only the marks that sit on it
// (a mark on a trailing apostrophe goes with the apostrophe).
while b > a {
let k = (a..b).rev().find(|&k| !deckcraft_model::words::is_combining_mark(chars[k].0)).unwrap_or(a);
if chars[k].0.is_alphanumeric() {
break;
}
b = k;
}
let text: String = chars[a..b].iter().map(|c| c.0).collect();
if text.is_empty() || text.chars().any(|c| c.is_ascii_digit()) || !text.chars().any(char::is_alphabetic) {
@@ -784,15 +805,18 @@ pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String]
.chain(repeated(p, default))
.collect::<Vec<_>>()
.into_iter()
.fold(vec![], |mut v: Vec<Word>, w| {
.fold((vec![], HashMap::new()), |(mut v, mut at): (Vec<Word>, HashMap<usize, usize>), w| {
// A repeated misspelt word is reported once, as repeated.
if let Some(i) = v.iter().position(|x| x.start == w.start) {
v[i] = w;
} else {
v.push(w);
match at.get(&w.start).and_then(|&i| v.get_mut(i)) {
Some(x) => *x = w,
None => {
at.insert(w.start, v.len());
v.push(w);
}
}
v
(v, at)
})
.0
}
/// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself).
@@ -819,6 +843,20 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
Dictionary::parse(AFF.as_bytes(), DIC.as_bytes())
}
#[test]
fn nfd_word_not_split() {
let p = deckcraft_model::Paragraph::new("Das na\u{ef}ve Caf\u{e9} und cafe\u{301} na\u{308}ive");
let w: Vec<String> = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect();
assert_eq!(w, ["Das", "na\u{ef}ve", "Caf\u{e9}", "und", "cafe\u{301}", "na\u{308}ive"]);
}
#[test]
fn a_mark_on_trailing_punctuation_is_trimmed_with_it() {
let p = deckcraft_model::Paragraph::new("word'\u{301} cafe\u{301}' x");
let w: Vec<String> = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect();
assert_eq!(w, ["word", "cafe\u{301}", "x"]);
}
#[test]
fn curly_apostrophe_matches_ascii_entry() {
let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes());
@@ -932,6 +970,23 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
assert_eq!(w, vec!["check", "done"]);
}
#[test]
fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() {
use deckcraft_model::{Paragraph, Run, RunProps};
// 100,000 copies of one word under a 1 MB language value: every word after the first is
// a repeat, and no word carries a copy of the value.
let lang = "x".repeat(1 << 20);
let p =
Paragraph { runs: vec![Run::with("the ".repeat(100_000), RunProps { lang: Some(lang), ..Default::default() })], ..Default::default() };
let words = paragraph_words(&p, "en-US");
assert_eq!(words.len(), 100_000);
assert!(words.iter().all(|w| w.lang.is_empty()));
let bad = misspelt(&p, "en-US", &[]);
assert_eq!(bad.len(), 99_999);
assert!(bad.iter().all(|w| w.repeated.is_some()));
assert!(paragraph_words(&Paragraph::new("word"), &"y".repeat(100)).iter().all(|w| w.lang.is_empty()));
}
#[test]
fn long_and_numeric_flags_and_latin1() {
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
+443 -1
View File
@@ -2410,6 +2410,23 @@ fn pdf_options_take_paper_and_orientation() {
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]
fn shape_line_sets_compound_and_arrow_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_color_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();
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_color_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)));
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_points, 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]
fn what_a_command_acts_on_names_only_keys_it_takes() {
for c in command_specs() {
@@ -5310,7 +5362,8 @@ fn opening_a_file_caps_absurd_effect_sizes() {
e.soft_edge = Some(f64::NAN);
let sw = e.outer_shadow.as_mut().unwrap();
(sw.blur, sw.dist, sw.sx) = (huge, huge, huge);
*e.reflection.as_mut().unwrap() = Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge };
*e.reflection.as_mut().unwrap() =
Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge, ..Default::default() };
}
let glow = Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge });
let runs: Vec<&mut deckcraft_model::text::Run> = match &mut sh.kind {
@@ -5572,6 +5625,170 @@ fn edit_points_conversion_is_one_undo_step() {
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
/// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret.
#[test]
@@ -5645,6 +5862,204 @@ fn box_of(s: &mut Session, id: u64) -> serde_json::Value {
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
/// already lines up with another shape. The nearest line used to be the fixed edge's, at distance 0.
#[test]
@@ -6064,3 +6479,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!(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 {
id: ShapeId,
handle: u8,
orig: Xfrm,
ids: Vec<ShapeId>,
handle: u8,
origs: Vec<(ShapeId, Xfrm)>,
start: Point,
},
LineEnd {
+43 -4
View File
@@ -13,6 +13,7 @@ struct MoveDrag {
}
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> {
let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
match drag {
@@ -26,10 +27,48 @@ impl Session {
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol);
}
Drag::Resize { id, handle, orig, start } => {
let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt);
let nx = self.snap_resize(nx, &orig, handle, id, tol);
self.set_xfrm(id, nx)?;
Drag::Resize { id, orig, ids, handle, origs, start } => {
let keep_aspect = mods.shift || self.is_picture(id);
let nx = resize_box(&orig, handle, p - start, keep_aspect, mods.alt);
let nx = self.snap_resize(nx, handle, &ids, tol);
let sx = if orig.w > 1e-9 { (nx.w / orig.w) * if nx.flip_h != orig.flip_h { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
let sy = if orig.h > 1e-9 { (nx.h / orig.h) * if nx.flip_v != orig.flip_v { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
let div = if mods.alt { 2.0 } else { 1.0 };
let hx = match handle {
0 | 6 | 7 => -1.0,
2..=4 => 1.0,
_ => 0.0,
};
let hy = match handle {
0..=2 => -1.0,
4..=6 => 1.0,
_ => 0.0,
};
for (tid, o) in &origs {
let target_nx = if *tid == id {
nx
} else {
let picture = self.is_picture(*tid);
let (sx, sy) = if picture && matches!(handle, 0 | 2 | 4 | 6) {
// Use the axis with the larger relative change so an unchanged
// dimension cannot cancel shrinking. Preserve each axis's flip.
let scale_x = 1.0 + sx;
let scale_y = 1.0 + sy;
let scale = if sx.abs() >= sy.abs() { scale_x.abs() } else { scale_y.abs() };
(scale.copysign(scale_x) - 1.0, scale.copysign(scale_y) - 1.0)
} else {
(sx, sy)
};
let lp = Affine::rotate(o.rot.to_radians())
* Point::new(
hx * sx * o.w / div * if o.flip_h { -1.0 } else { 1.0 },
hy * sy * o.h / div * if o.flip_v { -1.0 } else { 1.0 },
);
resize_box(o, handle, Vec2::new(lp.x, lp.y), picture, mods.alt)
};
self.set_xfrm(*tid, target_nx)?;
}
}
Drag::LineEnd { id, end, orig } => {
let a = orig.affine();
+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.
pub(super) fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {
pub(super) fn snap_resize(&mut self, x: Xfrm, handle: u8, exclude: &[ShapeId], tol: f64) -> Xfrm {
if x.rot != 0.0 {
self.tool.guides.clear();
return x;
@@ -241,7 +241,7 @@ impl Session {
let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6));
let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) };
let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) };
let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
let (off, guides) = self.snap_edges(r, exclude, tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
let mut nx = x;
if moves_left {
nx.x += off.x;
+6 -2
View File
@@ -216,6 +216,7 @@ pub(super) fn in_table_cell(st: &crate::DocState, id: ShapeId, p: Point) -> bool
})
}
/// Resolve a shape's target at slide point `p`, using its effective fill for interior hits.
pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit> {
let x = xfrm_of(&st.doc, &st.selection, sh);
let inv = x.affine().inverse();
@@ -267,8 +268,11 @@ pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) ->
return None;
}
let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box);
// Empty interiors of unfilled, text-less shapes don't catch clicks.
let filled = !matches!(sh.fill, Some(deckcraft_model::Fill::None)) || sh.style.is_some();
// Match rendering: an explicit No Fill overrides the style, and style/theme fills
// can themselves be No Fill. Empty, text-less interiors then let clicks through.
let fill =
cmd::text::ctx_for(&st.doc, &st.selection).map(|ctx| deckcraft_model::resolve::fill(&ctx, sh).0).unwrap_or_else(|| sh.fill.clone());
let filled = !matches!(fill, None | Some(deckcraft_model::Fill::None));
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
let in_geo = {
use deckcraft_geom::KurboShape;
+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> {
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.
@@ -303,8 +304,12 @@ impl Session {
Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?,
Some(Hit::Resize { id, handle }) => {
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
let ids: Vec<ShapeId> = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] };
let origs: Vec<(ShapeId, Xfrm)> =
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
let ids: Vec<ShapeId> = origs.iter().map(|(id, _)| *id).collect();
self.begin("Size");
self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p });
self.tool.drag = Some(Drag::Resize { id, orig, ids, handle, origs, start: p });
}
Some(Hit::LineEnd { id, end }) => {
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
@@ -436,6 +441,7 @@ impl Session {
Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()}))
}
/// Handle a pointer release and finalize the active tool interaction.
fn up(&mut self, p: Point, mods: Mods) -> Result<Value> {
let drag = self.tool.drag.take();
self.tool.guides.clear();
@@ -558,10 +564,22 @@ impl Session {
out = self.add_ink(points)?;
}
}
Some(Drag::Resize { id, .. }) => {
Some(Drag::Resize { origs, .. }) => {
let changed: Vec<ShapeId> = origs
.iter()
.filter_map(|(id, orig)| {
let current = self.active().and_then(|st| st.shape(*id).map(|s| xfrm_of(&st.doc, &st.selection, s)))?;
(current != *orig).then_some(*id)
})
.collect();
if !changed.is_empty() {
for id in changed {
let _ = cmd::text::fit_text_box(self, id);
self.refit_after_resize(id)?;
}
}
self.commit();
let _ = cmd::text::fit_text_box(self, id);
self.refit_after_resize(id)?;
}
Some(Drag::TextSelect { before, .. }) => {
out = self.paint_if_selection_changed(before, out)?;
+6 -1
View File
@@ -227,6 +227,9 @@ impl FontDb {
}
}
/// Most caret records read for one ligature glyph.
const MAX_LIGATURE_CARETS: usize = 64;
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
@@ -236,9 +239,12 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
let Ok(cov) = list.coverage() else { return Vec::new() };
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
// The limit applies to the records read, not to the carets kept: a font can list thousands of
// records it then has to be read through for every ligature laid out.
let mut out: Vec<i32> = lig
.caret_values()
.iter()
.take(MAX_LIGATURE_CARETS)
.filter_map(|cv| cv.ok())
.filter_map(|cv| match cv {
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
@@ -246,7 +252,6 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
// Format 2 is a contour point index; outlines aren't consulted here.
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
})
.take(64)
.collect();
out.sort_unstable();
out
+217 -20
View File
@@ -30,12 +30,15 @@ pub fn bundled() -> Vec<&'static [u8]> {
/// Open substitutes for families documents often ask for but we can't ship (metric-compatible
/// where one exists), tried in order after the family itself.
pub fn substitutes(family: &str) -> &'static [&'static str] {
match family.to_ascii_lowercase().as_str() {
let f = family.to_ascii_lowercase();
if is_monospace_family(&f) {
return &["Liberation Mono", "Courier New", "Menlo", "Courier", "DejaVu Sans Mono"];
}
match f.as_str() {
"calibri" | "calibri light" => &["Carlito", "Liberation Sans", "Arial", "Inter"],
"cambria" | "cambria math" => &["Caladea", "Liberation Serif", "Times New Roman", "Merriweather"],
"arial" | "helvetica" | "helvetica neue" | "arial nova" => &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"],
"times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times", "Merriweather"],
"courier new" | "courier" | "consolas" | "menlo" => &["Liberation Mono", "Courier New", "Menlo", "Courier"],
"aptos" | "aptos display" | "aptos narrow" | "segoe ui" | "segoe ui light" | "segoe ui semibold" | "tahoma" | "verdana" => {
&["Inter", "Helvetica Neue", "Arial", "Liberation Sans"]
}
@@ -52,12 +55,77 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {
}
}
/// Families that are monospaced: when not installed, documents keep a monospaced substitute
/// ([`substitutes`]) so code blocks and columns stay aligned instead of falling back to
/// proportional Calibri.
pub fn is_monospace_family(family: &str) -> bool {
let f = family.to_ascii_lowercase();
let s = f.trim();
if matches!(
s,
"courier new"
| "courier"
| "cousine"
| "consolas"
| "menlo"
| "monaco"
| "source code pro"
| "jetbrains mono"
| "cascadia code"
| "cascadia code pl"
| "cascadia mono"
| "cascadia mono pl"
| "fira code"
| "fira mono"
| "inconsolata"
| "dejavu sans mono"
| "sf mono"
| "roboto mono"
| "hack"
| "ubuntu mono"
| "pt mono"
| "liberation mono"
| "andale mono"
| "lucida console"
| "lucida sans typewriter"
| "fixedsys"
| "terminal"
| "monospace"
| "iosevka"
| "monolisa"
| "envy code r"
| "hasklig"
) {
return true;
}
s.starts_with("courier")
|| s.starts_with("consolas")
|| s.starts_with("inconsolata")
|| s.starts_with("iosevka")
|| s.ends_with(" mono")
|| s.ends_with(" code")
|| s.ends_with(" console")
|| s.ends_with(" typewriter")
|| s.ends_with(" monospace")
|| s.split_whitespace().any(|w| w == "mono" || w == "terminal" || w == "monospace")
}
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
/// them breaks into the same lines.
fn has_metric_twin(family: &str) -> bool {
matches!(
family.to_ascii_lowercase().as_str(),
"calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"
"calibri"
| "calibri light"
| "cambria"
| "cambria math"
| "arial"
| "helvetica"
| "times new roman"
| "times"
| "courier new"
| "courier"
| "cousine"
)
}
@@ -300,7 +368,7 @@ struct CatalogEntry {
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
outlines: Mutex<OutlineCache>,
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock<Vec<CatalogEntry>>,
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
@@ -371,6 +439,38 @@ const SYSTEM_FALLBACKS: &[&str] = &[
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
const OUTLINE_CACHE_MAX: usize = 50_000;
/// Most path elements one glyph outline may expand to. Composite and `VARC` glyphs can draw a
/// shared outline many times over; a glyph past this draws nothing rather than holding megabytes.
const MAX_OUTLINE_ELEMENTS: usize = 1 << 15;
/// Most path elements the outline cache holds across all glyphs (about 230 MB at most) before it
/// starts over: distinct glyph ids can each expand one dense outline.
const OUTLINE_CACHE_MAX_ELEMENTS: usize = 1 << 22;
/// Glyph outlines by (face, glyph), with the path elements they hold.
#[derive(Default)]
struct OutlineCache {
paths: HashMap<(u32, u32), Arc<BezPath>>,
elements: usize,
}
impl OutlineCache {
fn get(&self, key: &(u32, u32)) -> Option<Arc<BezPath>> {
self.paths.get(key).cloned()
}
fn insert(&mut self, key: (u32, u32), path: Arc<BezPath>) {
let n = path.elements().len();
if let Some(old) = self.paths.remove(&key) {
self.elements = self.elements.saturating_sub(old.elements().len());
}
if self.paths.len() >= OUTLINE_CACHE_MAX || self.elements.saturating_add(n) > OUTLINE_CACHE_MAX_ELEMENTS {
self.paths.clear();
self.elements = 0;
}
self.paths.insert(key, path);
self.elements = self.elements.saturating_add(n);
}
}
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
@@ -626,6 +726,10 @@ const MAX_COLLECTION_FACES: u32 = 256;
const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384;
/// Maximum named instances read from a single face.
const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024;
/// Maximum faces (named instances counted separately) enumerated from one font file, across all
/// of a collection's faces: the per-collection and per-face limits multiply, and each face found
/// is then built with its own shaper.
const MAX_ENUMERATED_FACES: usize = 4096;
fn distinct_face_indices(entries: impl IntoIterator<Item = (u32, u32)>) -> Vec<u32> {
let mut offsets = std::collections::HashSet::new();
@@ -662,21 +766,30 @@ fn enumerate_faces(data: &[u8]) -> Vec<Found> {
Err(_) => Vec::new(),
};
let mut out = Vec::new();
// Named instances read and plain faces kept, across the whole file.
let mut budget = MAX_ENUMERATED_FACES;
for i in indices {
if budget == 0 {
break;
}
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
let Some((family, style)) = face_names(&f) else { continue };
let axes = f.axes();
let mut named = Vec::new();
let mut seen = std::collections::HashSet::new();
let instances = f.named_instances();
for ni in instances.iter().take(capped_named_instance_count(instances.len())) {
let Some(left) = budget.checked_sub(1) else { break };
budget = left;
let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue };
if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) {
if !seen.insert(style.to_ascii_lowercase()) {
continue;
}
let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect();
named.push((i, style, coords));
}
if named.is_empty() {
budget = budget.saturating_sub(1);
out.push((i, family, style, Vec::new()));
} else {
out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c)));
@@ -837,7 +950,7 @@ impl FontDb {
}
Self {
faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()),
outlines: Mutex::new(OutlineCache::default()),
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock::new(Vec::new()),
font_dirs,
@@ -1080,6 +1193,7 @@ impl FontDb {
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
/// Monospaced families keep a monospaced substitute instead of falling back to Calibri.
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> {
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
@@ -1088,6 +1202,9 @@ impl FontDb {
if let Some(twin) = office_twin(family) {
return self.face(twin, style);
}
if is_monospace_family(family) {
return self.face(family, style);
}
self.face(MISSING_FONT_FAMILY, style)
}
@@ -1441,41 +1558,72 @@ impl FontDb {
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
return p;
}
let mut pen = FlipPen(BezPath::new());
let mut pen = FlipPen::new(MAX_OUTLINE_ELEMENTS);
if let Some(f) = face.skrifa()
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
}
let p = Arc::new(pen.0);
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
cache.insert(key, p.clone());
let p = Arc::new(pen.finish());
self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone());
p
}
}
struct FlipPen(BezPath);
/// Collects a glyph outline, y flipped, up to `limit` path elements. Past the limit it ignores
/// what follows and [`FlipPen::finish`] gives an empty path: a cut-off outline would fill wrongly.
struct FlipPen {
path: BezPath,
limit: usize,
over: bool,
}
impl FlipPen {
fn new(limit: usize) -> Self {
FlipPen { path: BezPath::new(), limit, over: false }
}
/// Room for one more element?
fn room(&mut self) -> bool {
if !self.over && self.path.elements().len() >= self.limit {
self.over = true;
self.path = BezPath::new();
}
!self.over
}
fn finish(self) -> BezPath {
if self.over { BezPath::new() } else { self.path }
}
}
impl OutlinePen for FlipPen {
fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to((x as f64, -y as f64));
if self.room() {
self.path.move_to((x as f64, -y as f64));
}
}
fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to((x as f64, -y as f64));
if self.room() {
self.path.line_to((x as f64, -y as f64));
}
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
if self.room() {
self.path.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
}
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
if self.room() {
self.path.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
}
}
fn close(&mut self) {
self.0.close_path();
if self.room() {
self.path.close_path();
}
}
}
@@ -1503,6 +1651,55 @@ mod font_dir_tests {
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE);
}
#[test]
fn faces_of_one_file_are_bounded_across_the_collection() {
// The per-collection and per-face limits together would allow far more.
assert!((MAX_COLLECTION_FACES as usize) * MAX_NAMED_INSTANCES_PER_FACE > 16 * MAX_ENUMERATED_FACES);
for data in bundled() {
assert!(enumerate_faces(data).len() <= MAX_ENUMERATED_FACES);
}
}
#[test]
fn an_outline_past_the_element_limit_draws_nothing() {
let mut pen = FlipPen::new(8);
pen.move_to(0.0, 0.0);
pen.line_to(1.0, 1.0);
pen.close();
assert_eq!(pen.finish().elements().len(), 3);
// A shared outline drawn over and over (composite and VARC glyphs).
let mut pen = FlipPen::new(8);
for i in 0..1_000_000 {
pen.move_to(0.0, 0.0);
pen.line_to(i as f32, 1.0);
pen.close();
}
assert_eq!(pen.path.elements().len(), 0);
assert!(pen.finish().elements().is_empty());
}
#[test]
fn outline_cache_is_bounded_by_elements() {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
for i in 1..MAX_OUTLINE_ELEMENTS {
path.line_to((i as f64, 0.0));
}
let path = Arc::new(path);
let mut cache = OutlineCache::default();
let glyphs = 4 * OUTLINE_CACHE_MAX_ELEMENTS / MAX_OUTLINE_ELEMENTS;
for gid in 0..glyphs as u32 {
cache.insert((1, gid), path.clone());
assert!(cache.elements <= OUTLINE_CACHE_MAX_ELEMENTS);
assert_eq!(cache.elements, cache.paths.values().map(|p| p.elements().len()).sum::<usize>());
}
assert!(cache.get(&(1, glyphs as u32 - 1)).is_some());
// Replacing an entry does not count it twice.
let before = cache.elements;
cache.insert((1, glyphs as u32 - 1), path.clone());
assert_eq!(cache.elements, before);
}
#[test]
fn xdg_data_dirs_contribute_their_fonts_folders() {
let home = Path::new("/home/ann");
+2 -1
View File
@@ -15,7 +15,8 @@ mod shape;
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
pub use fontdb::{
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, is_monospace_family, substitutes,
system_font_dirs,
};
pub use harfrust::Feature;
use harfrust::Tag;
+161 -38
View File
@@ -37,56 +37,146 @@ pub struct ShapeParams<'a> {
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
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
let (Some(pre), Some(text), Some(post)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
let (Some(_), Some(text), Some(_)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
return Vec::new();
};
let mut out = Vec::with_capacity(text.len());
let chunks = chunks(text, MAX_SHAPE_CHUNK);
let mut pieces: Vec<Vec<ShapedGlyph>> = Vec::with_capacity(chunks.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
buf.set_pre_context(pre);
buf.set_post_context(post);
for (i, c) in text.char_indices() {
buf.add(map(c), (range.start + i) as u32);
}
buf.guess_segment_properties();
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
buf.set_script(s);
}
if let Some(l) = params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
{
buf.set_language(l);
}
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
});
// Pieces of one item share the script the whole item would be guessed to have.
let item_script = (chunks.len() > 1 && params.script.is_none()).then(|| {
let mut probe = UnicodeBuffer::new();
for c in text.chars() {
probe.add(map(c), 0);
}
probe.guess_segment_properties();
probe.script()
});
for c in &chunks {
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 };
let mut buf = UnicodeBuffer::new();
buf.set_pre_context(pre);
buf.set_post_context(post);
for (i, c) in piece.char_indices() {
buf.add(map(c), (r.start + i) as u32);
}
buf.guess_segment_properties();
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
buf.set_script(s);
} else if let Some(s) = item_script {
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()
&& let Some(f) = face.skrifa()
{
if shaped.is_none() {
// No shaper for this face: one glyph per character from the cmap, in visual order.
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), face.location());
for (i, c) in text.char_indices() {
let g = cmap.map(map(c)).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
}
if params.rtl {
out.reverse();
return cmap_glyphs(face, para, range, params.rtl, &map);
}
// Each piece is in visual order; right to left, the later pieces come first.
if params.rtl {
pieces.reverse();
}
let mut out = Vec::with_capacity(pieces.iter().map(Vec::len).sum());
for p in pieces {
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
}
@@ -121,4 +211,37 @@ mod tests {
// Not a char boundary.
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:?}");
}
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
for (missing, twin) in
[("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")]
{
for (missing, twin) in [
("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) {
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("Aptos").contains(&"Inter"));
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]
+9 -9
View File
@@ -85,14 +85,14 @@ fn chart_parts_share_media_byte_and_item_budgets() {
let mut p = fixture(vec![7; 600]);
p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
let bytes = save(&p).unwrap();
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err(), "chart payloads share the cumulative media budget");
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err(), "chart parts share the media item budget");
assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok());
assert!(load_within(&bytes, Budget::media(1199, 2)).is_err(), "chart payloads share the cumulative media budget");
assert!(load_within(&bytes, Budget::media(1200, 1)).is_err(), "chart parts share the media item budget");
let duplicated = rewrite_manifest(&bytes, |manifest| {
let media = manifest["presentation"]["media"].as_array_mut().unwrap();
media.push(media[0].clone());
});
assert!(load_within(&duplicated, Budget { total: 1200, items: 2 }).is_err(), "duplicate media IDs still count as listed items");
assert!(load_within(&duplicated, Budget::media(1200, 2)).is_err(), "duplicate media IDs still count as listed items");
}
#[test]
@@ -104,9 +104,9 @@ fn legacy_inline_chart_bytes_are_budgeted_and_preserved() {
part.remove("nativeEntry");
part.insert("bytes".into(), json!(vec![9_u8; 600]));
});
assert_eq!(load_within(&legacy, Budget { total: 1200, items: 2 }).unwrap().0, p);
assert!(load_within(&legacy, Budget { total: 1199, items: 2 }).is_err());
assert!(load_within(&legacy, Budget { total: 1200, items: 1 }).is_err());
assert_eq!(load_within(&legacy, Budget::media(1200, 2)).unwrap().0, p);
assert!(load_within(&legacy, Budget::media(1199, 2)).is_err());
assert!(load_within(&legacy, Budget::media(1200, 1)).is_err());
}
#[test]
@@ -207,7 +207,7 @@ fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() {
}
// A failed CRC may already have inflated bytes. Continuing with the same remaining
// budget would let each corrupt member consume that work budget again.
let result = load_within(&bytes, Budget { total: 10, items: 2 });
let result = load_within(&bytes, Budget::media(10, 2));
assert!(result.is_err(), "two corrupt 10-byte members must not reuse a 10-byte budget");
assert!(result.unwrap_err().to_string().contains("chart-parts/0"));
}
@@ -228,7 +228,7 @@ fn duplicate_physical_zip_names_are_indexed_and_inflated_once() {
assert_eq!(replaced, 2);
let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
assert_eq!(z.file_names().filter(|n| n.starts_with("chart-parts/")).count(), 1);
let (q, damaged) = load_within(&bytes, Budget { total: 10, items: 2 }).unwrap();
let (q, damaged) = load_within(&bytes, Budget::media(10, 2)).unwrap();
assert_eq!(damaged.len(), 1, "the second reference is missing, not inflated again");
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
+58 -13
View File
@@ -29,12 +29,24 @@ const MAX_ENTRY: u64 = 2 << 30;
const MAX_TOTAL_MEDIA: u64 = 4 << 30;
/// Media items and chart sidecar parts one file may list.
const MAX_MEDIA_ITEMS: usize = 100_000;
/// Largest `presentation.json` we will inflate. The manifest holds no media bytes, so it is far
/// smaller than a media entry may be; inflating and parsing one is what dominates memory.
const MAX_MANIFEST: u64 = 256 << 20;
/// What one load may inflate.
#[derive(Clone, Copy)]
struct Budget {
total: u64,
items: usize,
manifest: u64,
}
#[cfg(test)]
impl Budget {
/// A media budget with the default manifest limit.
fn media(total: u64, items: usize) -> Self {
Budget { total, items, manifest: MAX_MANIFEST }
}
}
#[derive(Debug, thiserror::Error)]
@@ -107,6 +119,7 @@ pub fn sniff(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK") && [MIMETYPE, LEGACY_MIMETYPE].iter().any(|m| bytes.windows(m.len()).take(200).any(|w| w == m.as_bytes()))
}
#[cfg(test)]
fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Vec<u8>> {
read_entry_max(z, name, MAX_ENTRY)
}
@@ -114,13 +127,30 @@ fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Vec<
/// Read one member, refusing it before inflating if it declares more than `max` bytes, and
/// stopping at `max` if it inflates further than it declared.
fn read_entry_max(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64) -> Result<Vec<u8>> {
read_entry_counted(z, name, max, &mut 0)
}
/// [`read_entry_max`] within `left` (and [`MAX_ENTRY`]), charging `left` for every byte inflated,
/// including those inflated before a decode or CRC error, so a member that fails late still
/// spends the shared budget it used.
fn read_entry_charged(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, left: &mut u64) -> Result<Vec<u8>> {
let mut spent = 0;
let out = read_entry_counted(z, name, (*left).min(MAX_ENTRY), &mut spent);
*left = left.saturating_sub(spent);
out
}
/// [`read_entry_max`], adding the bytes inflated to `spent` whether or not the read succeeds.
fn read_entry_counted(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64, spent: &mut u64) -> Result<Vec<u8>> {
let f = z.by_name(name).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
if f.size() > max {
return Err(FormatError::Damaged(format!("{name} is too large")));
}
let mut out = Vec::with_capacity(f.size().min(64 << 20) as usize);
let mut f = f.take(max.saturating_add(1));
f.read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
let read = f.read_to_end(&mut out);
*spent = spent.saturating_add(out.len() as u64);
read.map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
if out.len() as u64 > max {
return Err(FormatError::Damaged(format!("{name} is too large")));
}
@@ -137,12 +167,15 @@ pub fn load(bytes: &[u8]) -> Result<Presentation> {
/// A chart member that is present but fails decoding, CRC or its entry limit refuses the file.
/// Linked media saved without its bytes isn't damage.
pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS })
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS, manifest: MAX_MANIFEST })
}
fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String>)> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?;
let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?;
if z.index_for_name("presentation.json").is_none() {
return Err(FormatError::NotOurs("no presentation.json".into()));
}
let json = read_entry_max(&mut z, "presentation.json", budget.manifest)?;
let m = parse_manifest(&json)?;
if m.format != "deckcraft" {
return Err(FormatError::NotOurs(m.format));
@@ -164,11 +197,9 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String
if z.by_name(&name).is_ok_and(|f| f.size() > left) {
return Err(FormatError::Damaged("embedded media is larger than DeckCraft can open".into()));
}
match read_entry_max(&mut z, &name, left.min(MAX_ENTRY)) {
Ok(data) => {
left -= data.len() as u64;
item.data = Arc::new(data);
}
// A failed read is charged for what it inflated, so later members can't reuse that budget.
match read_entry_charged(&mut z, &name, &mut left) {
Ok(data) => item.data = Arc::new(data),
Err(_) if linked_only => {}
Err(e) => damaged.push(e.to_string()),
}
@@ -188,9 +219,11 @@ pub fn repair(p: &mut Presentation) {
}
p.fix_next_id();
let first_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
// Indexed once: a per-slide scan of every layout is slides × layouts work.
let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
let mut seen = std::collections::HashSet::new();
for i in 0..p.slides.len() {
let needs_layout = p.slides.get(i).is_some_and(|s| p.layout(s.layout).is_none());
let needs_layout = p.slides.get(i).is_some_and(|s| !layouts.contains(&s.layout));
let dup = p.slides.get(i).is_some_and(|s| !seen.insert(s.id));
if needs_layout || dup {
// An exhausted id space leaves a duplicate slide as it is: the engine refuses to
@@ -331,16 +364,20 @@ fn failure(src: &[u8]) -> std::result::Result<Manifest, (String, (usize, usize))
/// nearest first, and a patch is kept only if the parse then gets further. (Files saved since
/// [`finite::Finite`] never contain such `null`s; this reads older ones.)
fn parse_manifest(json: &[u8]) -> Result<Manifest> {
let mut src = json.to_vec();
let mut last = match failure(&src) {
// Parse the inflated text as it is: only a file that fails on a `null` number is copied for
// patching (the copy is bounded by `MAX_MANIFEST`).
let mut last = match failure(json) {
Ok(m) => return Ok(m),
Err(f) => f,
};
if !is_null_number(&last.0) {
return Err(FormatError::Damaged(last.0));
}
let mut src = json.to_vec();
let mut budget = MAX_REPARSE_BYTES;
'fixes: for _ in 0..MAX_NULL_FIXES {
let (msg, pos) = &last;
let number = msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number");
if !number {
if !is_null_number(msg) {
break;
}
let end = offset(&src, pos.0, pos.1).unwrap_or(src.len());
@@ -374,6 +411,11 @@ fn parse_manifest(json: &[u8]) -> Result<Manifest> {
Err(FormatError::Damaged(last.0))
}
/// Is this parse error a `null` where a number belongs (a NaN or infinity serde_json wrote)?
fn is_null_number(msg: &str) -> bool {
msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number")
}
/// Byte offset of a 1-based `line`:`column`.
fn offset(src: &[u8], line: usize, column: usize) -> Option<usize> {
let mut start = 0;
@@ -453,6 +495,9 @@ fn sane_shadow(s: &mut deckcraft_model::style::Shadow) {
fn sane_reflection(r: &mut deckcraft_model::style::Reflection) {
r.blur = effect_size(r.blur, MAX_REFLECTION);
r.dist = effect_size(r.dist, MAX_REFLECTION);
if r.align.as_deref().is_some_and(|v| !matches!(v, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) {
r.align = None;
}
}
/// Run properties a list style hands down to the text that inherits it.
+160 -4
View File
@@ -11,6 +11,31 @@ fn chart_sidecars_are_optional_for_older_native_files() {
assert_eq!(serde_json::from_value::<Chart>(json).expect("empty sidecar"), chart);
}
#[test]
fn alternate_run_language_survives_two_native_saves_and_absence() {
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
let mut p = defaults::new_presentation(None);
Arc::make_mut(&mut p.slides[0]).shapes[0].text = Some(TextBody {
paragraphs: vec![Paragraph {
runs: vec![
Run::with("bonjour", RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() }),
Run::with(" plain", RunProps { lang: Some("en-US".into()), ..Default::default() }),
],
..Default::default()
}],
..Default::default()
});
let mut q = load(&save(&p).expect("save")).expect("load");
for _ in 0..2 {
let runs = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].runs;
assert_eq!(runs[0].props.lang.as_deref(), Some("en-US"));
assert_eq!(runs[0].props.alt_lang.as_deref(), Some("fr-FR"));
assert_eq!(runs[1].props.lang.as_deref(), Some("en-US"));
assert_eq!(runs[1].props.alt_lang, None);
q = load(&save(&q).expect("save again")).expect("load again");
}
}
#[test]
fn native_save_keeps_chart_relationships_and_payloads() {
use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType};
@@ -48,6 +73,38 @@ fn native_save_keeps_chart_relationships_and_payloads() {
}
}
#[test]
fn native_table_direction_and_column_text_survive_two_saves() {
use deckcraft_model::table::{Cell, Table};
use deckcraft_model::{Shape, ShapeId, ShapeKind};
use std::sync::Arc;
for rtl in [Some(true), Some(false), None] {
let mut p = defaults::new_presentation(None);
let mut table = Table::new(1, 2, 200.0, 30.0);
table.rtl = rtl;
table.rows[0].cells[0] = Cell::new("Left column");
table.rows[0].cells[1] = Cell::new("Right column");
let shape = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Table(table), ..Default::default() };
Arc::make_mut(&mut p.slides[0]).shapes.push(shape);
let mut q = load(&save(&p).expect("save")).expect("load");
for _ in 0..2 {
let table = q.slides[0]
.shapes
.iter()
.find_map(|s| match &s.kind {
ShapeKind::Table(table) => Some(table),
_ => None,
})
.expect("table shape");
assert_eq!(table.rtl, rtl);
assert_eq!(table.rows[0].cells[0].text.paragraphs[0].runs[0].text, "Left column");
assert_eq!(table.rows[0].cells[1].text.paragraphs[0].runs[0].text, "Right column");
q = load(&save(&q).expect("save again")).expect("load again");
}
}
}
/// The same package without the entries `drop` names.
fn without(bytes: &[u8], drop: &str) -> Vec<u8> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
@@ -93,9 +150,60 @@ fn media_budget_is_cumulative() {
p.add_media("b.bin", "application/octet-stream", vec![2; 600]);
let bytes = save(&p).unwrap();
// Exactly at the limit opens; one byte under is refused, though each item fits.
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err());
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err());
assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok());
assert!(load_within(&bytes, Budget::media(1199, 2)).is_err());
assert!(load_within(&bytes, Budget::media(1200, 1)).is_err());
}
#[test]
fn corrupt_media_is_charged_for_what_it_inflated() {
let mut p = Presentation::default();
p.add_media("a.png", "image/png", b"PICTURE-AAAAAAAAAAAA".to_vec());
p.add_media("b.png", "image/png", b"PICTURE-BBBBBBBBBBBB".to_vec());
let mut bytes = save(&p).unwrap();
// Corrupt both stored members, so each fails its CRC only after inflating all its bytes.
for tag in [b"PICTURE-AAAA", b"PICTURE-BBBB"] {
let at = bytes.windows(tag.len()).position(|w| w == tag).unwrap();
bytes[at] ^= 0xff;
}
// Each member fits the budget on its own; the second must not reuse what the first spent.
let result = load_within(&bytes, Budget::media(20, 2));
assert!(result.is_err(), "two corrupt 20-byte members must not share a 20-byte budget");
// With room for both, the file opens and reports both.
let (_, damaged) = load_within(&bytes, Budget::media(40, 2)).unwrap();
assert_eq!(damaged.len(), 2, "{damaged:?}");
}
#[test]
fn oversized_manifest_is_refused_before_inflating() {
let bytes = save(&Presentation::default()).unwrap();
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
let size = z.by_name("presentation.json").unwrap().size();
assert!(load_within(&bytes, Budget { total: 0, items: 0, manifest: size }).is_ok());
let err = load_within(&bytes, Budget { total: 0, items: 0, manifest: size - 1 }).unwrap_err();
assert!(err.to_string().contains("too large"), "{err}");
}
#[test]
fn many_slides_on_missing_layouts_are_repaired() {
let mut p = Presentation::default();
let template = p.slides[0].clone();
let good = template.layout;
p.slides = (0..2000u32)
.map(|i| {
let mut s = (*template).clone();
s.id = deckcraft_model::SlideId(10_000 + i);
if i % 2 == 1 {
s.layout = deckcraft_model::LayoutId(900_000 + i);
}
Arc::new(s)
})
.collect();
p.next_id = 20_000;
repair(&mut p);
assert!(p.validate().is_empty(), "{:?}", p.validate().first());
assert!(p.slides.iter().all(|s| p.layout(s.layout).is_some()));
assert_eq!(p.slides[0].layout, good);
}
#[test]
@@ -124,7 +232,7 @@ fn repeated_media_ids_are_read_once() {
}
let bytes = out.finish().unwrap().into_inner();
// 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once.
let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0;
let q = load_within(&bytes, Budget::media(1000, 51)).unwrap().0;
assert_eq!(q.media.len(), 1);
assert_eq!(q.media[0].data.len(), 1000);
// And the deck saves again.
@@ -208,6 +316,54 @@ fn a_file_saved_with_non_finite_numbers_reopens() {
assert_eq!(load(&save(&back).unwrap()).unwrap(), back);
}
#[test]
fn reflection_transform_survives_two_native_saves_and_absent_fields_stay_absent() {
use deckcraft_model::style::Reflection;
use deckcraft_model::{Effects, Shape};
let mut p = Presentation::default();
let reflection = Reflection {
blur: 1.0,
start_alpha: 0.5,
end_alpha: 0.0,
end_pos: 0.75,
dist: 2.0,
st_pos: Some(20_000),
fade_dir: Some(0),
sx: Some(75_000),
sy: Some(-50_000),
kx: Some(600_000),
align: Some("bl".into()),
rotate_with_shape: Some(false),
..Default::default()
};
let shape = Shape { effects: Some(Effects { reflection: Some(reflection.clone()), ..Default::default() }), ..Default::default() };
Arc::make_mut(&mut p.slides[0]).shapes.push(shape);
let once = load(&save(&p).expect("save")).expect("load");
let twice = load(&save(&once).expect("save again")).expect("load again");
assert_eq!(twice.slides[0].shapes.last().and_then(|s| s.effects.as_ref()).and_then(|e| e.reflection.as_ref()), Some(&reflection));
let absent = Reflection {
blur: 1.0,
start_alpha: 1.0,
end_alpha: 0.0,
end_pos: 1.0,
dist: 0.0,
st_pos: None,
dir: None,
fade_dir: None,
sx: None,
sy: None,
kx: None,
ky: None,
align: None,
rotate_with_shape: None,
};
let shape = Shape { effects: Some(Effects { reflection: Some(absent.clone()), ..Default::default() }), ..Default::default() };
Arc::make_mut(&mut p.slides[0]).shapes = vec![shape];
let reopened = load(&save(&p).expect("save absent values")).expect("load absent values");
assert_eq!(reopened.slides[0].shapes.last().and_then(|s| s.effects.as_ref()).and_then(|e| e.reflection.as_ref()), Some(&absent));
}
/// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too,
/// including a `null` inside a tagged enum, where serde reports only the enclosing object.
#[test]
+22 -3
View File
@@ -71,7 +71,10 @@ impl Unit {
/// `2.08"` style text for a length in points.
pub fn format(self, pt: f64) -> String {
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())
}
/// 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.
pub fn rot_norm(&self) -> f64 {
let r = self.rot % 360.0;
if r < 0.0 { r + 360.0 } else { r }
let r = self.rot.rem_euclid(360.0);
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 {
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]
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] {
@@ -266,6 +277,14 @@ mod tests {
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]
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);
+31 -2
View File
@@ -126,6 +126,21 @@ fn split_curve(c: &[Point], n: usize, out: &mut Vec<Point>) {
out.extend([p1, p2, p3]);
}
/// A piece length ordered totally, so it can sit in a heap.
#[derive(PartialEq)]
struct Share(f64);
impl Eq for Share {}
impl PartialOrd for Share {
fn partial_cmp(&self, o: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(o))
}
}
impl Ord for Share {
fn cmp(&self, o: &Self) -> std::cmp::Ordering {
self.0.total_cmp(&o.0)
}
}
/// Brings a contour to `n` curves. Unlike manim (which splits every curve equally often), the extra
/// splits go to the longest pieces, so a long side gets more points than a short one.
fn refine(c: &Contour, n: usize) -> Contour {
@@ -136,11 +151,15 @@ fn refine(c: &Contour, n: usize) -> Contour {
let curves: Vec<&[Point]> = (0..have).filter_map(|k| c.pts.get(3 * k..3 * k + 4)).collect();
let len: Vec<f64> = curves.iter().map(|q| curve_length(q) + 1e-9).collect();
let mut parts = vec![1usize; curves.len()];
// The curve with the longest piece gets the next split (the lowest index on a tie); a heap keeps
// this O(n log n) instead of rescanning every curve for each split.
let mut heap: std::collections::BinaryHeap<(Share, std::cmp::Reverse<usize>)> =
len.iter().enumerate().map(|(k, l)| (Share(*l), std::cmp::Reverse(k))).collect();
for _ in 0..n - have {
let share = |k: usize| len.get(k).copied().unwrap_or(0.0) / parts.get(k).copied().unwrap_or(1) as f64;
let best = (1..curves.len()).fold(0, |b, k| if share(k) > share(b) { k } else { b });
let Some((_, std::cmp::Reverse(best))) = heap.pop() else { break };
if let Some(p) = parts.get_mut(best) {
*p += 1;
heap.push((Share(len.get(best).copied().unwrap_or(0.0) / *p as f64), std::cmp::Reverse(best)));
}
}
let mut out = Vec::with_capacity(1 + 3 * n);
@@ -420,6 +439,16 @@ mod tests {
assert_eq!(contours(&to_path(&cs)), cs);
}
#[test]
fn refine_many_curves_finishes_at_the_asked_count() {
let mut c = Contour { closed: false, pts: vec![Point::ZERO] };
for i in 1..=20_000 {
line_to(&mut c.pts, Point::new(f64::from(i), f64::from(i % 7)));
}
let r = refine(&c, 40_000);
assert_eq!(r.curves(), 40_000);
}
#[test]
fn refine_splits_the_longest_curves_and_keeps_the_shape() {
let c = Contour { closed: false, pts: vec![Point::ZERO, Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)] };
+19 -9
View File
@@ -928,11 +928,25 @@ impl Warp {
k_fill = 1.0;
}
zoom = zoom.max(k_fill);
// Per band, measured once: its widest line and its first and last lines' baselines.
let mut stats: Vec<Option<(f64, f64, f64)>> = vec![None; nb];
for (l, &b) in lines.iter().zip(&band_of) {
let w = l.extent.1 - l.extent.0;
if let Some(slot) = stats.get_mut(b) {
match slot {
Some((widest, _, last)) => {
*widest = widest.max(w);
*last = l.ref_y;
}
None => *slot = Some((0.0f64.max(w), l.ref_y, l.ref_y)),
}
}
}
let mut maps = Vec::with_capacity(n);
let mut offsets = Vec::new();
for (i, l) in lines.iter().enumerate() {
let b = band_of.get(i).copied().unwrap_or(0);
let in_band = |j: &usize| band_of.get(*j) == Some(&b);
let band = stats.get(b).copied().flatten();
match self.bands.get(b) {
Some(Band::Fill { .. }) | None => {
let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0));
@@ -941,16 +955,12 @@ impl Warp {
}
Some(Band::Follow { path, anchor }) => {
let len = path.length();
let widest = (0..n).filter(in_band).filter_map(|j| lines.get(j)).map(|l| l.extent.1 - l.extent.0).fold(0.0, f64::max);
let widest = band.map_or(0.0, |s| s.0);
let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 };
let members: Vec<&WarpLine> = (0..n).filter(in_band).filter_map(|j| lines.get(j)).collect();
let ref_y = match anchor {
LineAnchor::Last => members.last().map(|l| l.ref_y),
LineAnchor::First => members.first().map(|l| l.ref_y),
LineAnchor::Middle => match (members.first(), members.last()) {
(Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0),
_ => None,
},
LineAnchor::Last => band.map(|s| s.2),
LineAnchor::First => band.map(|s| s.1),
LineAnchor::Middle => band.map(|s| (s.1 + s.2) / 2.0),
}
.unwrap_or(l.ref_y);
// Lines off the guide run on a parallel curve at their natural length.
+17
View File
@@ -319,3 +319,20 @@ fn arch_and_inflate_handles_follow_the_pointer() {
assert!(more > adjust_defaults("textInflate")[0], "{more}");
assert!((more - 20_000.0).abs() < 1.0, "{more}");
}
#[test]
fn many_lines_on_a_follow_band_are_mapped_from_one_measurement() {
// 50,000 lines on an arch: each line's scale and anchor come from the band measured once.
let w = warp("textArchUp", &[], rect()).unwrap();
let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6);
let lines: Vec<WarpLine> = (0..50_000)
.map(|i| {
let y = i as f64 * 0.01;
WarpLine { ink: Some(Rect::new(190.0, y, 230.0, y + 1.0)), extent: (190.0, 230.0), ref_y: y + 1.0 }
})
.collect();
let m = w.mapper(&lines, inner);
// The last line sits on the arc, as with two lines.
let last = lines.len() - 1;
assert!(m.map(last, Point::new(210.0, lines[last].ref_y)).y.abs() < 0.05);
}
+44 -7
View File
@@ -566,6 +566,19 @@ impl<'a> SliceDecoder<'a> {
(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.
fn derive_direct(&mut self, b8_mask: u8) -> Result<()> {
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 (mv0, mv1) = if ltr {
(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]])
};
let (mv0, mv1) = if ltr { (mv_col, [0, 0]) } else { Self::temporal_direct_mvs(dsf, mv_col) };
([ref0, 0], [mv0, mv1])
};
self.set_motion(x, y, unit, unit, refs, mvs);
@@ -1481,3 +1488,33 @@ impl<'a> SliceDecoder<'a> {
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");
}
}
+3 -1
View File
@@ -326,7 +326,9 @@ impl SliceHeader {
/// entry_point_offset list (tiles / wavefront substreams).
fn parse_entry_points(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> {
self.entry_points.clear();
// A fresh list, not `clear()`: a dependent segment starts as a copy of its independent
// segment's header, and clearing would keep that copy's allocation for every segment.
self.entry_points = Vec::new();
if pps.tiles_enabled || pps.entropy_coding_sync {
let n = r.read_ue()?;
ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range");
+89 -34
View File
@@ -758,17 +758,20 @@ impl Chart {
}
fn stacked_value_range(&self, n: usize) -> (f64, f64) {
let mut lo: f64 = 0.0;
let mut hi: f64 = 0.0;
for i in 0..n {
let (mut p, mut m) = (0.0, 0.0);
for s in &self.series {
let v = s.values.get(i).copied().flatten().unwrap_or(0.0);
if v >= 0.0 { p += v } else { m += v }
// Sums per category, from the values the series hold (an absent one adds nothing).
let (mut pos, mut neg) = (vec![0.0f64; n], vec![0.0f64; n]);
for s in &self.series {
for (i, v) in s.values.iter().enumerate().take(n) {
let v = v.unwrap_or(0.0);
if v >= 0.0 {
pos[i] += v;
} else {
neg[i] += v;
}
}
hi = hi.max(p);
lo = lo.min(m);
}
let hi = pos.iter().fold(0.0f64, |a, &p| a.max(p));
let lo = neg.iter().fold(0.0f64, |a, &m| a.min(m));
(lo, hi)
}
@@ -1348,40 +1351,50 @@ impl Chart {
/// The box chart's groups (distinct category labels in order of appearance; one unnamed
/// group without categories) and, per series, each group's summary (None when it has no
/// points).
///
/// Each value is put in its group's bucket once (groups are found by name in a map), and the
/// series x groups result is held to [`MAX_BOX_CELLS`]: groups past it are left out.
pub fn box_groups(&self) -> (Vec<String>, Vec<Vec<Option<BoxStats>>>) {
let max_groups = (MAX_BOX_CELLS / self.series.len().max(1)).max(1);
let mut groups: Vec<String> = Vec::new();
let mut index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for c in &self.categories {
if !groups.contains(c) {
if !index.contains_key(c.as_str()) && groups.len() < max_groups {
index.insert(c, groups.len());
groups.push(c.clone());
}
}
if groups.is_empty() {
groups.push(String::new());
index.insert("", 0);
}
// A value past the categories belongs to the unnamed group, when there is one.
let past_end = index.get("").copied();
let stats = self
.series
.iter()
.map(|s| {
groups
.iter()
.map(|g| {
let pts: Vec<f64> = s
.values
.iter()
.enumerate()
.filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g))
.filter_map(|(_, v)| *v)
.filter(|v| v.is_finite())
.collect();
box_stats_by(pts, self.inclusive_quartiles)
})
.collect()
let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); groups.len()];
for (i, v) in s.values.iter().enumerate() {
let Some(v) = v.filter(|v| v.is_finite()) else { continue };
let g = match self.categories.get(i) {
Some(c) => index.get(c.as_str()).copied(),
None => past_end,
};
if let Some(b) = g.and_then(|g| buckets.get_mut(g)) {
b.push(v);
}
}
buckets.into_iter().map(|pts| box_stats_by(pts, self.inclusive_quartiles)).collect()
})
.collect();
(groups, stats)
}
}
/// Most series x group summaries [`Chart::box_groups`] makes.
pub const MAX_BOX_CELLS: usize = 1_000_000;
/// Widens the (min, max) extent `ext` to include `v`.
fn widen(ext: &mut (f64, f64), v: f64) {
ext.0 = ext.0.min(v);
@@ -1454,33 +1467,41 @@ impl Chart {
let n = self.categories.len().max(s.values.len());
let levels: Vec<&Vec<String>> = self.category_levels.iter().take(MAX_LEVELS).collect();
// Each leaf's path from the top level down, blanks filled from the row above.
let mut last: Vec<String> = vec![String::new(); levels.len()];
let paths: Vec<Vec<String>> = (0..n)
// Labels are shared, not copied: an outer label given once stands for every leaf under it.
use std::rc::Rc;
let empty: Rc<str> = Rc::from("");
let mut last: Vec<Rc<str>> = vec![empty.clone(); levels.len()];
let paths: Vec<Vec<Rc<str>>> = (0..n)
.map(|i| {
let mut path = Vec::with_capacity(levels.len() + 1);
for (d, lv) in levels.iter().enumerate().rev() {
let l = lv.get(i).map(|x| x.trim()).unwrap_or("");
if !l.is_empty() {
last[d] = l.to_string();
last[d] = Rc::from(l);
// A new outer label starts fresh inner groups.
for inner in last.iter_mut().take(d) {
inner.clear();
*inner = empty.clone();
}
}
path.push(last[d].clone());
}
path.push(self.categories.get(i).cloned().unwrap_or_default());
path.push(self.categories.get(i).map_or_else(|| empty.clone(), |c| Rc::from(c.as_str())));
path
})
.collect();
let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0);
fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> {
fn build(rows: &[(usize, &[std::rc::Rc<str>])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> {
let mut out: Vec<TreeNode> = Vec::new();
let mut i = 0;
while i < rows.len() {
let (idx, path) = rows[i];
if path.len() <= 1 {
out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] });
out.push(TreeNode {
name: path.first().map(|p| p.to_string()).unwrap_or_default(),
value: value(idx),
leaf: Some(idx),
children: vec![],
});
i += 1;
continue;
}
@@ -1489,14 +1510,14 @@ impl Chart {
while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head {
j += 1;
}
let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect();
let sub: Vec<(usize, &[std::rc::Rc<str>])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect();
let children = build(&sub, value);
out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children });
out.push(TreeNode { name: head.to_string(), value: children.iter().map(|c| c.value).sum(), leaf: None, children });
i = j;
}
out
}
let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect();
let rows: Vec<(usize, &[Rc<str>])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect();
build(&rows, &value)
}
}
@@ -1607,6 +1628,40 @@ mod chartex_tests {
assert!(c.category_tree().iter().all(|n| n.leaf.is_some()));
}
#[test]
fn large_box_hierarchy_and_stacked_charts_take_linear_work() {
// 50,000 distinct categories in one series: grouped through a map, one pass.
let n = 50_000;
let mut c = Chart::sample(ChartType::BoxWhisker);
c.series.truncate(1);
c.categories = (0..n).map(|i| format!("g{i}")).collect();
c.series[0].values = (0..n).map(|i| Some(i as f64)).collect();
let (groups, stats) = c.box_groups();
assert_eq!((groups.len(), stats[0].len()), (n, n));
assert!(stats[0].iter().all(Option::is_some));
// 2,000 series cap the groups so the result stays within MAX_BOX_CELLS.
c.series = vec![c.series[0].clone(); 2000];
let (groups, stats) = c.box_groups();
assert_eq!(groups.len(), MAX_BOX_CELLS / 2000);
assert!(stats.iter().all(|s| s.len() == groups.len()));
// One long outer label over 50,000 leaves is shared, not copied per leaf.
let mut t = Chart::sample(ChartType::Treemap);
t.series.truncate(1);
t.categories = (0..n).map(|i| format!("c{i}")).collect();
t.series[0].values = (0..n).map(|_| Some(1.0)).collect();
let mut outer = vec![String::new(); n];
outer[0] = "o".repeat(1 << 16);
t.category_levels = vec![outer];
let tree = t.category_tree();
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].children.len(), n);
// Stacked sums over many sparse series.
let mut st = Chart::sample(ChartType::StackedBar);
st.categories = (0..n).map(|i| i.to_string()).collect();
st.series = (0..2000).map(|k| Series { values: vec![Some(1.0); if k == 0 { n } else { 1 }], ..Default::default() }).collect();
assert!(st.value_range().1 >= 2000.0, "{:?}", st.value_range());
}
#[test]
fn box_groups_follow_category_labels() {
let c = Chart::sample(ChartType::BoxWhisker);
+151 -22
View File
@@ -78,45 +78,57 @@ pub fn insert(body: &mut TextBody, pos: Pos, text: &str, props: Option<RunProps>
if body.paragraphs.is_empty() {
body.paragraphs.push(Paragraph::default());
}
let (mut pi, mut ch) = clamp(body, pos);
let props = props.unwrap_or_else(|| props_at(body, (pi, ch)));
let (pi0, mut ch) = clamp(body, pos);
let props = props.unwrap_or_else(|| props_at(body, (pi0, ch)));
let text = text.replace("\r\n", "\n").replace('\r', "\n");
// Work on the paragraph being edited apart from the body, so each new paragraph is pushed to a
// list and the body's vector is spliced once instead of shifted for every `\n`.
let Some(slot) = body.paragraphs.get_mut(pi0) else { return (pi0, ch) };
let mut cur = std::mem::take(slot);
let mut done: Vec<Paragraph> = Vec::new();
let mut first = true;
for part in text.split('\n') {
if !first {
// Split paragraph pi at ch.
let Some(p) = body.paragraphs.get_mut(pi) else { break };
let idx = split_at(p, ch);
let tail: Vec<Run> = p.runs.drain(idx..).collect();
let newp =
Paragraph { level: p.level, props: p.props.clone(), runs: tail, def_props: p.def_props.clone(), end_props: p.end_props.clone() };
p.end_props = props.clone();
body.paragraphs.insert(pi + 1, newp);
pi += 1;
// Split the current paragraph at ch.
let idx = split_at(&mut cur, ch);
let tail: Vec<Run> = cur.runs.drain(idx..).collect();
let newp = Paragraph {
level: cur.level,
props: cur.props.clone(),
runs: tail,
def_props: cur.def_props.clone(),
end_props: cur.end_props.clone(),
};
cur.end_props = props.clone();
done.push(std::mem::replace(&mut cur, newp));
ch = 0;
}
first = false;
// The run index just after the last insertion: the next one lands there with no rescan.
let mut next: Option<usize> = None;
for (k, seg) in part.split('\u{b}').enumerate() {
let Some(p) = body.paragraphs.get_mut(pi) else { break };
if k > 0 {
let idx = split_at(p, ch);
p.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
let idx = next.unwrap_or_else(|| split_at(&mut cur, ch));
cur.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
ch += 1;
next = Some(idx + 1);
}
if seg.is_empty() {
continue;
}
let idx = split_at(p, ch);
p.runs.insert(idx, Run::with(seg, props.clone()));
let idx = next.unwrap_or_else(|| split_at(&mut cur, ch));
cur.runs.insert(idx, Run::with(seg, props.clone()));
ch += seg.chars().count();
next = Some(idx + 1);
}
if let Some(p) = body.paragraphs.get_mut(pi) {
p.normalize();
if p.runs.is_empty() {
p.end_props = props.clone();
}
cur.normalize();
if cur.runs.is_empty() {
cur.end_props = props.clone();
}
}
let pi = pi0 + done.len();
done.push(cur);
body.paragraphs.splice(pi0..=pi0, done);
(pi, ch)
}
@@ -252,8 +264,16 @@ pub fn slice(body: &TextBody, a: Pos, b: Pos) -> TextBody {
/// Insert a formatted body at `pos` (paste): first paragraph merges into the current one.
pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
let mut caret = clamp(body, pos);
// Plain text runs go straight in at the run index after the previous one and the paragraph is
// normalized once, not rescanned and merged for every source run.
let mut next: Option<usize> = None;
let mut dirty = false;
for (k, sp) in src.paragraphs.iter().enumerate() {
if k > 0 {
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
p.normalize();
}
(next, dirty) = (None, false);
caret = insert(body, caret, "\n", None);
if let Some(p) = body.paragraphs.get_mut(caret.0) {
p.level = sp.level;
@@ -261,6 +281,22 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
}
}
for r in &sp.runs {
if r.kind == RunKind::Text
&& !r.text.is_empty()
&& !r.text.contains(['\n', '\r', '\u{b}'])
&& let Some(p) = body.paragraphs.get_mut(caret.0)
{
let idx = next.unwrap_or_else(|| split_at(p, caret.1));
p.runs.insert(idx, Run::with(&r.text, r.props.clone()));
caret.1 += r.text.chars().count();
(next, dirty) = (Some(idx + 1), true);
continue;
}
// Anything else takes the general path, after the pending runs are merged.
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
p.normalize();
}
(next, dirty) = (None, false);
match r.kind {
RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
@@ -274,6 +310,9 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
}
}
}
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
p.normalize();
}
caret
}
@@ -469,6 +508,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.
pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
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
.iter()
.map(|s| match mode {
@@ -479,10 +521,14 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
// `title` restarts after whitespace, sentence case after sentence punctuation.
let title = mode == "title";
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() {
out.extend(c.to_uppercase());
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 {
out.extend(c.to_lowercase());
}
@@ -490,6 +536,7 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
start = true;
}
}
base += s.chars().count();
out
}
})
@@ -504,6 +551,20 @@ mod tests {
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]
fn insert_and_split() {
let mut b = body("Hello");
@@ -546,6 +607,74 @@ mod tests {
assert_eq!(caret, (0, 5));
}
/// The paste as it ran before the fast path: one `insert` (and normalize) per source run.
fn insert_body_by_runs(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
let mut caret = clamp(body, pos);
for (k, sp) in src.paragraphs.iter().enumerate() {
if k > 0 {
caret = insert(body, caret, "\n", None);
if let Some(p) = body.paragraphs.get_mut(caret.0) {
p.level = sp.level;
p.props = sp.props.clone();
}
}
for r in &sp.runs {
match r.kind {
RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
_ => {
if let Some(p) = body.paragraphs.get_mut(caret.0) {
let idx = split_at(p, caret.1);
p.runs.insert(idx, r.clone());
caret.1 += r.char_len();
}
}
}
}
}
caret
}
#[test]
fn paste_matches_inserting_run_by_run() {
let bold = |t: &str| Run::with(t, RunProps { bold: Some(true), ..Default::default() });
let mut src = body("");
src.paragraphs = vec![
Paragraph {
runs: vec![Run::with("ab", RunProps::default()), Run::with("cd", RunProps::default()), bold("ef"), bold("g")],
..Default::default()
},
Paragraph {
runs: vec![
bold("h"),
Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break },
Run::with("i", RunProps::default()),
],
level: 1,
..Default::default()
},
Paragraph { runs: vec![Run::with("jk", RunProps::default())], ..Default::default() },
];
for at in [0, 1, 3, 6] {
let mut dest = body("XYZabc");
format(&mut dest, (0, 2), (0, 4), &|p| p.italic = Some(true));
let mut want = dest.clone();
let want_caret = insert_body_by_runs(&mut want, (0, at), &src);
let caret = insert_body(&mut dest, (0, at), &src);
assert_eq!(caret, want_caret, "caret at {at}");
assert_eq!(format!("{:?}", dest.paragraphs), format!("{:?}", want.paragraphs), "paste at {at}");
}
}
#[test]
fn multiline_insert_splits_into_paragraphs() {
let mut b = body("abcd");
let c = insert(&mut b, (0, 2), "x\ny\u{b}z\n\nw", None);
assert_eq!(c, (3, 1));
let texts: Vec<String> = b.paragraphs.iter().map(Paragraph::text).collect();
assert_eq!(texts, ["abx", "y\u{b}z", "", "wcd"]);
}
#[test]
fn words_and_moves() {
let b = body("Hello brave world");
+3 -1
View File
@@ -1112,11 +1112,13 @@ impl Presentation {
pub fn validate(&self) -> Vec<String> {
let mut out = vec![];
let mut seen = std::collections::HashSet::new();
// Indexed once: a per-slide scan of every layout is slides × layouts work.
let layouts: std::collections::HashSet<_> = self.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
for s in &self.slides {
if !seen.insert(s.id) {
out.push(format!("duplicate slide id {}", s.id));
}
if self.layout(s.layout).is_none() {
if !layouts.contains(&s.layout) {
out.push(format!("slide {} uses missing layout {}", s.id, s.layout));
}
let mut ids = std::collections::HashSet::new();
+13
View File
@@ -143,9 +143,14 @@ fn group(digits: &str, sep: char) -> String {
out
}
/// Longest number format applied, as in Excel (255 characters); a longer one formats as
/// General, so a chart cannot make every label expand a huge format.
pub const MAX_CODE_LEN: usize = 255;
/// `v` formatted with the Excel number format `code` in language `lang` (axis labels: the text
/// section of a format doesn't apply).
pub fn format(code: &str, v: f64, lang: Option<&str>) -> String {
let code = if code.len() > MAX_CODE_LEN * 4 || code.chars().count() > MAX_CODE_LEN { "General" } else { code };
let (sep, dec) = marks(lang);
if !v.is_finite() {
return String::new();
@@ -299,6 +304,14 @@ mod tests {
const RUB: &str = r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#;
#[test]
fn an_overlong_format_is_general() {
let code = "0".repeat(100_000);
assert_eq!(format(&code, 12.5, None), format("General", 12.5, None));
let ok = "0".repeat(MAX_CODE_LEN);
assert_eq!(format(&ok, 7.0, None).len(), MAX_CODE_LEN);
}
#[test]
fn scientific_subnormal() {
assert_eq!(format("0.00E+00", 5e-324, None), "4.94E-324");
+3
View File
@@ -249,6 +249,9 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
rtl_col: Some(false),
upright: Some(false),
space_first_last: Some(false),
compat_ln_spc: None,
vert_overflow: None,
horz_overflow: None,
});
b
}
+35 -2
View File
@@ -55,10 +55,12 @@ impl ColorRef {
};
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 {
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::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,
})
}
@@ -457,13 +459,44 @@ pub struct Glow {
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(default, rename_all = "camelCase")]
pub struct Reflection {
pub blur: f64,
pub start_alpha: f64,
pub end_alpha: f64,
pub end_pos: f64,
pub dist: f64,
/// DrawingML reflection transform values retain their native integer units.
pub st_pos: Option<i32>,
pub dir: Option<i32>,
pub fade_dir: Option<i32>,
pub sx: Option<i32>,
pub sy: Option<i32>,
pub kx: Option<i32>,
pub ky: Option<i32>,
pub align: Option<String>,
pub rotate_with_shape: Option<bool>,
}
impl Default for Reflection {
fn default() -> Self {
Self {
blur: 0.0,
start_alpha: 0.0,
end_alpha: 0.0,
end_pos: 0.0,
dist: 0.0,
st_pos: None,
dir: Some(5_400_000),
fade_dir: None,
sx: None,
sy: Some(-100_000),
kx: None,
ky: None,
align: Some("bl".into()),
rotate_with_shape: Some(false),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+6
View File
@@ -23,6 +23,8 @@ pub struct Cell {
pub h_merge: bool,
pub v_merge: bool,
pub vertical: Option<crate::text::TextDir>,
pub anchor_ctr: Option<bool>,
pub horz_overflow: Option<crate::text::TextOverflow>,
}
impl Cell {
@@ -55,6 +57,9 @@ pub struct Table {
pub last_col: bool,
pub band_row: bool,
pub band_col: bool,
/// Whether columns are ordered right-to-left (`a:tblPr/@rtl`).
#[serde(skip_serializing_if = "Option::is_none")]
pub rtl: Option<bool>,
/// `a:tblPr` fill: a fill behind the whole table (over the style's own background).
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<Fill>,
@@ -72,6 +77,7 @@ impl Default for Table {
last_col: false,
band_row: true,
band_col: false,
rtl: None,
background: None,
}
}
+12
View File
@@ -327,3 +327,15 @@ fn only_web_and_mail_links_are_safe_to_open() {
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());
}
+108 -5
View File
@@ -121,6 +121,46 @@ pub struct BodyProps {
pub upright: Option<bool>,
/// Space before the first paragraph and after the last count too (`spcFirstLastPara`).
pub space_first_last: Option<bool>,
pub compat_ln_spc: Option<bool>,
pub vert_overflow: Option<TextOverflow>,
pub horz_overflow: Option<TextOverflow>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextOverflow {
Overflow,
Clip,
Ellipsis,
}
impl TextOverflow {
pub fn xml(self) -> &'static str {
match self {
Self::Overflow => "overflow",
Self::Clip => "clip",
Self::Ellipsis => "ellipsis",
}
}
pub fn from_xml(value: &str) -> Option<Self> {
Some(match value {
"overflow" => Self::Overflow,
"clip" => Self::Clip,
"ellipsis" => Self::Ellipsis,
_ => return None,
})
}
/// A `horzOverflow` value: `ST_TextHorzOverflowType` has only `overflow` and `clip`.
pub fn from_horz_xml(value: &str) -> Option<Self> {
Self::from_xml(value).filter(|v| *v != Self::Ellipsis)
}
/// The `horzOverflow` attribute to write, leaving out `ellipsis`, which is vertical only.
pub fn horz_xml(v: Option<Self>) -> Option<&'static str> {
v.filter(|v| *v != Self::Ellipsis).map(Self::xml)
}
}
impl BodyProps {
@@ -144,6 +184,9 @@ impl BodyProps {
self.rtl_col = self.rtl_col.or(p.rtl_col);
self.upright = self.upright.or(p.upright);
self.space_first_last = self.space_first_last.or(p.space_first_last);
self.compat_ln_spc = self.compat_ln_spc.or(p.compat_ln_spc);
self.vert_overflow = self.vert_overflow.or(p.vert_overflow);
self.horz_overflow = self.horz_overflow.or(p.horz_overflow);
}
}
@@ -157,6 +200,7 @@ pub enum Align {
/// Justified with kashida (tatweel) elongation for Arabic (`justLow`).
JustLow,
Distributed,
ThaiDistributed,
}
impl Align {
@@ -168,6 +212,7 @@ impl Align {
Align::Justify => "just",
Align::JustLow => "justLow",
Align::Distributed => "dist",
Align::ThaiDistributed => "thaiDist",
}
}
pub fn from_xml(s: &str) -> Option<Self> {
@@ -175,7 +220,8 @@ impl Align {
"l" => Align::Left,
"ctr" => Align::Center,
"r" => Align::Right,
"just" | "thaiDist" => Align::Justify,
"just" => Align::Justify,
"thaiDist" => Align::ThaiDistributed,
"justLow" => Align::JustLow,
"dist" => Align::Distributed,
_ => return None,
@@ -242,6 +288,8 @@ pub struct ParaProps {
pub bullet_color_follow_text: Option<bool>,
/// Bullet size as a fraction of the text size.
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 tabs: Option<Vec<TabStop>>,
pub default_tab: Option<f64>,
@@ -286,11 +334,13 @@ impl ParaProps {
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;
}
if self.bullet_size.is_none() && self.bullet_size_follow_text.is_none() {
if p.bullet_size_follow_text == Some(true) {
if self.bullet_size.is_none() && self.bullet_size_points.is_none() && self.bullet_size_follow_text.is_none() {
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);
} else {
self.bullet_size = p.bullet_size;
@@ -310,6 +360,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)]
#[serde(rename_all = "camelCase")]
pub enum Caps {
@@ -425,6 +502,8 @@ pub struct RunProps {
/// Action on mouse over (Insert ▸ Action ▸ Mouse Over on selected text, `a:hlinkMouseOver`).
pub hover: Option<Hyperlink>,
pub lang: Option<String>,
#[serde(deserialize_with = "bounded_language")]
pub alt_lang: Option<String>,
pub shadow: Option<crate::style::Shadow>,
/// `a:innerShdw` on the run. Outer shadow stays in `shadow`.
pub inner_shadow: Option<crate::style::Shadow>,
@@ -434,6 +513,14 @@ pub struct RunProps {
pub soft_edge: Option<f64>,
/// Spelling/grammar errors are not flagged.
pub no_proof: Option<bool>,
pub normalize_height: Option<bool>,
}
fn bounded_language<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(deserializer).map(|lang| lang.map(|value| value.chars().take(256).collect()))
}
impl RunProps {
@@ -462,12 +549,14 @@ impl RunProps {
link,
hover,
lang,
alt_lang,
shadow,
inner_shadow,
glow,
reflection,
soft_edge,
no_proof
no_proof,
normalize_height
);
}
}
@@ -664,6 +753,20 @@ mod tests {
use super::*;
use deckcraft_color::Rgba;
#[test]
fn alternate_language_inherits_and_native_input_is_bounded() {
let mut child = RunProps::default();
child.inherit(&RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() });
assert_eq!(child.lang.as_deref(), Some("en-US"));
assert_eq!(child.alt_lang.as_deref(), Some("fr-FR"));
let oversized = "x".repeat(300);
let json = serde_json::json!({"altLang": oversized});
let props: RunProps = serde_json::from_value(json).expect("run props");
assert_eq!(props.alt_lang.as_deref().map(str::len), Some(256));
assert_eq!(serde_json::from_value::<RunProps>(serde_json::json!({})).expect("old run props"), RunProps::default());
}
#[test]
fn explicit_bullet_follow_text_resets_block_inheritance() {
let parent = ParaProps {
+6 -1
View File
@@ -90,11 +90,16 @@ fn base_class(c: char) -> Class {
c if c.is_alphanumeric() => Class::Word,
c if is_symbol(c) => Class::Symbol,
// 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,
}
}
/// 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> {
let mut out: Vec<Cluster> = 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
/// embedding. No font bytes or vendor assets are stored as test fixtures.
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())] {
let mut offset = 0;
while offset + old.len() <= bytes.len() {
+41
View File
@@ -14,6 +14,7 @@ mod maps;
mod opc;
mod png;
pub(crate) mod read;
pub use read::MAX_TAG_BYTES;
mod tables;
mod write;
mod xml;
@@ -174,3 +175,43 @@ mod import_theme_tests {
assert!(crate::import_theme(&out.finish().unwrap().into_inner()).is_err());
}
}
#[cfg(test)]
mod table_direction_tests {
use deckcraft_model::table::{Cell, Table};
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, defaults};
use std::sync::Arc;
fn table(p: &Presentation) -> &Table {
p.slides[0]
.shapes
.iter()
.find_map(|shape| match &shape.kind {
ShapeKind::Table(table) => Some(table),
_ => None,
})
.expect("table shape")
}
#[test]
fn table_direction_and_column_order_survive_two_pptx_saves() {
for rtl in [Some(true), Some(false), None] {
let mut p = defaults::new_presentation(None);
let mut t = Table::new(1, 2, 200.0, 30.0);
t.rtl = rtl;
t.rows[0].cells[0] = Cell::new("First column");
t.rows[0].cells[1] = Cell::new("Second column");
let shape = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Table(t), ..Default::default() };
Arc::make_mut(&mut p.slides[0]).shapes.push(shape);
let mut roundtrip = crate::import(&crate::export(&p).expect("export")).expect("import");
for _ in 0..2 {
let t = table(&roundtrip);
assert_eq!(t.rtl, rtl);
assert_eq!(t.rows[0].cells[0].text.paragraphs[0].runs[0].text, "First column");
assert_eq!(t.rows[0].cells[1].text.paragraphs[0].runs[0].text, "Second column");
roundtrip = crate::import(&crate::export(&roundtrip).expect("export again")).expect("import again");
}
}
}
}
+33 -3
View File
@@ -171,6 +171,7 @@ fn rels_of(doc: &xml::Doc, part: &str) -> Rels {
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("external"));
let target_raw = target.to_string();
let target = if external { target_raw.clone() } else { resolve(&base, target) };
out.by_id.entry(id.to_string()).or_insert(out.items.len());
out.items.push(Rel {
id: id.to_string(),
kind: ty.rsplit('/').next().unwrap_or(ty).to_string(),
@@ -211,11 +212,13 @@ pub struct Rel {
#[derive(Clone, Debug, Default)]
pub struct Rels {
pub items: Vec<Rel>,
/// Id → index of its first relationship in `items`.
by_id: HashMap<String, usize>,
}
impl Rels {
pub fn get(&self, id: &str) -> Option<&Rel> {
self.items.iter().find(|r| r.id == id)
self.by_id.get(id).and_then(|&i| self.items.get(i))
}
pub fn target(&self, id: &str) -> Option<&str> {
self.get(id).map(|r| r.target.as_str())
@@ -339,23 +342,29 @@ pub fn relative(from: &str, to: &str) -> String {
#[derive(Default)]
pub struct RelsOut {
pub items: Vec<(String, String, String, bool)>,
/// (type, target, external) → id of the relationship [`RelsOut::add`] made for it.
added: HashMap<(String, String, bool), String>,
}
impl RelsOut {
/// Add (or reuse) a relationship; `kind` is a full type URI or a suffix of the standard base.
pub fn add(&mut self, kind: &str, target: &str, external: bool) -> String {
let ty = if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") };
if let Some((id, ..)) = self.items.iter().find(|(_, t, tg, e)| *t == ty && tg == target && *e == external) {
let key = (ty, target.to_string(), external);
if let Some(id) = self.added.get(&key) {
return id.clone();
}
let id = format!("rId{}", self.items.len() + 1);
self.items.push((id.clone(), ty, target.to_string(), external));
self.items.push((id.clone(), key.0.clone(), key.1.clone(), external));
self.added.insert(key, id.clone());
id
}
/// Always add a new relationship even if an identical one exists.
pub fn add_new(&mut self, kind: &str, target: &str, external: bool) -> String {
let ty = if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") };
let id = format!("rId{}", self.items.len() + 1);
// `add` reuses the first relationship for a target, whichever call made it.
self.added.entry((ty.clone(), target.to_string(), external)).or_insert_with(|| id.clone());
self.items.push((id.clone(), ty, target.to_string(), external));
id
}
@@ -500,6 +509,27 @@ mod tests {
assert!(p.get("e").is_some());
}
#[test]
fn many_relationships_are_found_by_index_and_first_id_wins() {
let n = 50_000;
let mut x = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for i in 0..n {
x.push_str(&format!(r#"<Relationship Id="rId{i}" Type="{RT_BASE}image" Target="../media/image{i}.png"/>"#));
}
x.push_str(&format!(r#"<Relationship Id="rId0" Type="{RT_BASE}image" Target="../media/dup.png"/></Relationships>"#));
let rels = rels_from_bytes(x.as_bytes(), "ppt/slides/slide1.xml");
assert_eq!(rels.target("rId0"), Some("ppt/media/image0.png"), "the first of a repeated id wins");
assert!((0..n).all(|i| rels.target(&format!("rId{i}")) == Some(&*format!("ppt/media/image{i}.png"))));
let mut out = RelsOut::default();
let ids: Vec<String> = (0..n).map(|i| out.add("hyperlink", &format!("https://example.com/{i}"), true)).collect();
assert_eq!(out.add("hyperlink", "https://example.com/7", true), ids[7], "an identical target reuses its id");
assert_eq!(out.add("hyperlink", "https://example.com/7", false), format!("rId{}", n + 1), "internal is distinct");
let fresh = out.add_new("image", "../media/a.png", false);
assert_ne!(out.add_new("image", "../media/a.png", false), fresh);
assert_eq!(out.add("image", "../media/a.png", false), fresh, "add reuses the first add_new made");
}
#[test]
fn backslash_zip_entry_names_are_found() {
let z = zip_of(&[("PPT\\Slides\\Slide1.xml", 3)]);
+91 -23
View File
@@ -40,6 +40,15 @@ pub struct Imp<'a> {
pub kept_sidecar_bytes: usize,
/// Cached kept sidecars by normalized chart part name.
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,
/// Whether a part has relationships of its own, by normalized part name: looked up once
/// however many embedded objects point at the part.
pub has_rels: HashMap<String, bool>,
}
impl Imp<'_> {
@@ -196,16 +205,64 @@ fn choice_has_model3d(el: &El, rels: &Rels) -> bool {
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.
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 mut out = Vec::new();
let mut seen = HashSet::new();
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 };
for tag in doc.root.children_named("tag").take(10_000) {
if let Some(name) = tag.attr("name") {
out.push((name.to_string(), tag.attr("val").unwrap_or("").to_string()));
if imp.tags_capped {
return out;
}
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
@@ -389,6 +446,10 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
vml_previews: HashMap::new(),
kept_sidecar_bytes: 0,
kept_charts: HashMap::new(),
tags_by_part: HashMap::new(),
tag_bytes: 0,
tags_capped: false,
has_rels: HashMap::new(),
};
let part = Part::new(&pkg, &theme_path, &doc);
let theme = theme::read_theme(&mut imp, &part, &doc.root);
@@ -427,6 +488,10 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
vml_previews: HashMap::new(),
kept_sidecar_bytes: 0,
kept_charts: HashMap::new(),
tags_by_part: HashMap::new(),
tag_bytes: 0,
tags_capped: false,
has_rels: HashMap::new(),
};
// Slide ids first so hyperlinks anywhere can point at slides.
let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides);
@@ -441,7 +506,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 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);
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_guides_and_show(&mut p, &imp, &pres_part);
read_notes_master(&mut imp, &mut p, &pres_part);
@@ -603,52 +668,55 @@ fn read_slides(
let pkg = imp.pkg;
let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
let mut file_slide_ids: HashMap<u32, SlideId> = HashMap::new();
let mut notes_by_target: HashMap<String, TextBody> = HashMap::new();
// A notes or comments part belongs to one slide; one shared by many must not be read (and
// copied) once per slide that names it.
let mut owned_parts: HashSet<String> = HashSet::new();
for (file_id, path, id) in slide_ids {
let Some(doc) = docs.get(&opc::norm(path)) else { continue };
let part = Part::new(pkg, path, doc);
let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout);
let mut slide = read_slide(imp, &part, doc, *id, layout);
if let Some(notes) = slide_notes(imp, &part, &mut notes_by_target) {
if let Some(notes) = slide_notes(imp, &part, &mut owned_parts) {
slide.notes = notes;
}
read_slide_comments(pkg, pres_part, &part, &mut slide);
read_slide_comments(pkg, pres_part, &part, &mut slide, &mut owned_parts);
file_slide_ids.insert(*file_id, *id);
p.slides.push(Arc::new(slide));
}
file_slide_ids
}
/// The slide's notes text, read once per notes part.
fn slide_notes(imp: &mut Imp, part: &Part, cache: &mut HashMap<String, TextBody>) -> Option<TextBody> {
/// The slide's notes text. A notes part is the notes of the first slide that names it.
fn slide_notes(imp: &mut Imp, part: &Part, owned: &mut HashSet<String>) -> Option<TextBody> {
let n = part.rels.first_of("notesSlide")?;
let key = opc::norm(&n.target);
if let Some(cached) = cache.get(&key) {
return Some(cached.clone());
if !owned.insert(opc::norm(&n.target)) {
log::warn!("pptx: notes part {} is named by more than one slide; kept on the first", n.target);
return None;
}
let pkg = imp.pkg;
let nd = load(pkg, &n.target)?;
let np = Part::new(pkg, &n.target, &nd);
let notes = read_notes(imp, &np, &nd.root);
cache.insert(key, notes.clone());
Some(notes)
Some(read_notes(imp, &np, &nd.root))
}
/// Comments. A modern part and the legacy part can describe the same threads (PowerPoint
/// writes both): the modern one wins, its flattened legacy copy is skipped.
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide) {
let mut parts: Vec<(Doc, bool)> = vec![];
/// Each comments part is read once, for the first slide that names it.
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide, owned: &mut HashSet<String>) {
let mut parts: Vec<(Doc, bool, bool)> = vec![];
for c in part.rels.all_of("comments") {
// An already-owned part is not re-read, but its kind still decides modern precedence.
let fresh = owned.insert(opc::norm(&c.target));
if let Some(cd) = pkg.xml(&c.target) {
// The content type names the modern part; a package that types it only through a
// Default entry instead of an Override is still modern when its root is p188.
let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188";
parts.push((cd, modern));
parts.push((cd, modern, fresh));
}
}
let has_modern = parts.iter().any(|(_, m)| *m);
for (cd, modern) in parts {
if has_modern && !modern {
let has_modern = parts.iter().any(|(_, m, _)| *m);
for (cd, modern, fresh) in parts {
if !fresh || (has_modern && !modern) {
continue;
}
slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern));
+80 -7
View File
@@ -23,6 +23,16 @@ pub fn pct(e: &El, name: &str) -> Option<f64> {
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.
pub fn ang(e: &El, name: &str) -> Option<f64> {
e.f64(name).map(|v| v / 60_000.0)
@@ -312,6 +322,15 @@ pub fn effects(el: &El) -> Effects {
end_alpha: pct(e, "endA").unwrap_or(0.0),
end_pos: pct(e, "endPos").unwrap_or(1.0),
dist: pt(e, "dist").unwrap_or(0.0),
st_pos: e.i64("stPos").and_then(|v| i32::try_from(v).ok()),
dir: e.i64("dir").and_then(|v| i32::try_from(v).ok()),
fade_dir: e.i64("fadeDir").and_then(|v| i32::try_from(v).ok()),
sx: e.i64("sx").and_then(|v| i32::try_from(v).ok()),
sy: e.i64("sy").and_then(|v| i32::try_from(v).ok()),
kx: e.i64("kx").and_then(|v| i32::try_from(v).ok()),
ky: e.i64("ky").and_then(|v| i32::try_from(v).ok()),
align: e.attr("algn").filter(|v| matches!(*v, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")).map(str::to_owned),
rotate_with_shape: e.bool("rotWithShape"),
})
}
_ => {}
@@ -358,6 +377,9 @@ pub fn body_pr(b: &El) -> BodyProps {
rtl_col: b.bool("rtlCol"),
upright: b.bool("upright"),
space_first_last: b.bool("spcFirstLastPara"),
compat_ln_spc: b.bool("compatLnSpc"),
vert_overflow: b.attr("vertOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml),
horz_overflow: b.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_horz_xml),
..Default::default()
};
for e in b.elements() {
@@ -454,9 +476,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),
"buFontTx" => pp.bullet_font_follow_text = Some(true),
"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),
"buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)),
"buSzTx" => pp.bullet_size_follow_text = Some(true),
"buSzTx" => {
pp.bullet_size = None;
pp.bullet_size_points = None;
pp.bullet_size_follow_text = Some(true);
}
"tabLst" => {
pp.tabs = Some(
e.children_named("tab")
@@ -530,6 +565,7 @@ fn typeface(e: &El) -> Option<String> {
pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps {
let mut p = RunProps {
lang: r.attr("lang").map(String::from),
alt_lang: r.attr("altLang").map(|lang| lang.chars().take(256).collect()),
size: r.f64("sz").map(|s| (s / 100.0).clamp(1.0, 4000.0)),
bold: r.bool("b"),
italic: r.bool("i"),
@@ -548,6 +584,7 @@ pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps {
spacing: r.f64("spc").map(|s| (s / 100.0).clamp(-4000.0, 4000.0)),
kern: r.f64("kern").map(|k| (k / 100.0).clamp(0.0, 4000.0)),
no_proof: r.bool("noProof"),
normalize_height: r.bool("normalizeH"),
..Default::default()
};
for e in r.elements() {
@@ -627,12 +664,22 @@ fn push_text_runs(para: &mut Paragraph, props: RunProps, text: &str) {
/// Appends an Office Math (a14:m) run: its plain text and its OMML.
fn push_math_run(para: &mut Paragraph, part: &Part, e: &El) {
if para.runs.len() < MAX_PARAGRAPH_RUNS {
let mut text = String::new();
let mut ts = vec![];
e.find_all("t", &mut ts);
for t in ts {
text.push_str(&t.text());
// The text of the outermost `t` elements: a `t` nested in another is already part of
// the outer one's text, and taking it again would copy it once per nesting level.
fn outer_t(e: &El, depth: usize, text: &mut String) {
if depth > crate::xml::MAX_DEPTH {
return;
}
for c in e.elements() {
if c.is("t") {
text.push_str(&c.text());
} else {
outer_t(c, depth + 1, text);
}
}
}
let mut text = String::new();
outer_t(e, 0, &mut text);
let omml = e.elements().map(|c| part.keep_inner(c)).collect::<String>();
para.runs.push(Run { text, props: RunProps::default(), kind: RunKind::Math { omml } });
}
@@ -677,6 +724,17 @@ mod tests {
use super::*;
use crate::xml::parse;
#[test]
fn nested_math_text_is_taken_once() {
// 200 `m:t` nested in each other around one word: the word, not 200 copies of it.
let x = format!(r#"<a14:m xmlns:a14="a" xmlns:m="m"><m:oMath>{}word{}</m:oMath></a14:m>"#, "<m:t>".repeat(200), "</m:t>".repeat(200));
let d = parse(x.as_bytes()).unwrap();
let part = Part { name: "ppt/slides/slide1.xml".into(), rels: Default::default(), ns: vec![], inner_ns: vec![] };
let mut para = Paragraph::default();
push_math_run(&mut para, &part, &d.root);
assert_eq!(para.runs[0].text, "word");
}
#[test]
fn colors_and_transforms() {
let d = parse(
@@ -692,6 +750,21 @@ mod tests {
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]
fn miter_limit_accepts_both_percentage_forms_with_schema_bounds() {
assert_eq!(parse_miter_limit("200000"), Some(200_000));
+5 -1
View File
@@ -475,7 +475,8 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
.into_iter()
.map(|id| {
let rel = part.rels.get(&id).filter(|r| !r.external)?;
if !imp.pkg.rels(&rel.target).items.is_empty() {
let pkg = imp.pkg;
if *imp.has_rels.entry(crate::opc::norm(&rel.target)).or_insert_with(|| !pkg.rels(&rel.target).items.is_empty()) {
return None;
}
let (kind, target) = (rel.ty.clone(), rel.target.clone());
@@ -757,6 +758,7 @@ fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
t.last_col = pr.bool("lastCol").unwrap_or(false);
t.band_row = pr.bool("bandRow").unwrap_or(false);
t.band_col = pr.bool("bandCol").unwrap_or(false);
t.rtl = pr.bool("rtl");
if let Some(id) = pr.child("tableStyleId").map(|e| e.text()) {
// A style the file defines is drawn from its definition (by its GUID).
// DeckCraft's own styles (written with GUIDs derived from their ids) map back to them.
@@ -805,6 +807,8 @@ fn cell(imp: &mut Imp, part: &Part, tc: &El) -> Cell {
]);
}
c.anchor = pr.attr("anchor").and_then(Anchor::from_xml);
c.anchor_ctr = pr.bool("anchorCtr");
c.horz_overflow = pr.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_horz_xml);
c.vertical = pr.attr("vert").and_then(TextDir::from_xml);
for (i, n) in ["lnL", "lnR", "lnT", "lnB"].iter().enumerate() {
if let Some(ln) = pr.child(n)
+112 -6
View File
@@ -53,25 +53,128 @@ pub fn transition(imp: &mut Imp, part: &Part, t: &El) -> Option<Transition> {
struct Found<'a> {
ctn: &'a El,
trigger: Option<u32>,
/// Click group the effect sits in: a new one starts at every time node whose start is `indefinite`.
group: usize,
/// Sum of the start delays of the time nodes between the click group and the effect, in ms.
offset: u32,
}
fn collect<'a>(e: &'a El, trigger: Option<u32>, out: &mut Vec<Found<'a>>, depth: usize) {
/// Where `collect` is in the time tree: the click group and the delay accumulated inside it.
#[derive(Clone, Copy)]
struct At {
trigger: Option<u32>,
group: usize,
offset: u32,
}
fn collect<'a>(e: &'a El, at: At, groups: &mut usize, out: &mut Vec<Found<'a>>, depth: usize) {
if depth > 64 || out.len() > 10_000 {
return;
}
for c in e.elements() {
if c.is("cTn") && c.has_attr("presetClass") {
out.push(Found { ctn: c, trigger });
out.push(Found { ctn: c, trigger: at.trigger, group: at.group, offset: at.offset });
continue;
}
let mut trig = trigger;
let mut at = at;
if c.is("seq")
&& let Some(ctn) = c.child("cTn")
&& ctn.attr("nodeType") == Some("interactiveSeq")
{
trig = ctn.path(&["stCondLst", "cond", "tgtEl", "spTgt"]).and_then(|s| s.u32("spid"));
at.trigger = ctn.path(&["stCondLst", "cond", "tgtEl", "spTgt"]).and_then(|s| s.u32("spid"));
}
if c.is("cTn")
&& let Some(cond) = c.path(&["stCondLst", "cond"])
{
if cond.attr("delay").map(str::trim) == Some("indefinite") {
*groups += 1;
at.group = *groups;
at.offset = 0;
} else {
at.offset = at.offset.saturating_add(ms_attr(cond, "delay").unwrap_or(0));
}
}
collect(c, at, groups, out, depth + 1);
}
}
/// How long an effect holds back an After Previous one, as the show timeline counts it.
fn span_ms(a: &Animation) -> u32 {
let iterations = if a.repeat == u32::MAX || a.repeat <= 1 { 1 } else { a.repeat.min(1_000) };
a.duration_ms.saturating_mul(if a.auto_reverse { 2 } else { 1 }).saturating_mul(iterations)
}
/// Start times (ms after the click) that the flat effect list plays at, effect after effect.
fn flat_starts(anims: &[&Animation]) -> Vec<u32> {
let (mut begin, mut end) = (0u32, 0u32);
anims
.iter()
.map(|a| {
if a.start == AnimStart::AfterPrevious {
begin = end;
}
let start = begin.saturating_add(a.delay_ms);
end = end.max(start.saturating_add(span_ms(a)));
start
})
.collect()
}
/// PowerPoint places each effect at the delays of all its enclosing time nodes, so a file may list
/// effects out of time order (a countdown written first, then effects at 5 s placed only by their
/// parent's delay). The flat list only knows "after/with previous" plus the effect's own delay, so
/// a click group listed out of time order, whose flat reading disagrees with the file, is sorted by
/// start time and its starts and delays rewritten to land every effect where PowerPoint plays it.
/// Every other group is left untouched.
fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool)], at: &[(Option<u32>, usize, u32)]) {
let mut groups: Vec<Vec<usize>> = vec![];
let mut slot: std::collections::HashMap<(Option<u32>, usize), usize> = std::collections::HashMap::new();
for (i, &(t, g, _)) in at.iter().enumerate() {
let n = *slot.entry((t, g)).or_insert_with(|| {
groups.push(vec![]);
groups.len() - 1
});
groups[n].push(i);
}
for idx in groups {
// More than one click in a group means the tree isn't one click per group: leave it.
if idx.iter().skip(1).any(|&i| out[i].0.start == AnimStart::OnClick) {
continue;
}
let abs: Vec<u32> = idx.iter().map(|&i| at[i].2.saturating_add(out[i].0.delay_ms)).collect();
// Only a file that lists effects out of time order needs this: files PowerPoint or DeckCraft
// save are in order, and their reading stays exactly as before.
if abs.is_sorted() {
continue;
}
let flat = flat_starts(&idx.iter().map(|&i| &out[i].0).collect::<Vec<_>>());
if flat == abs {
continue;
}
// The group's click stays on whichever effect now plays first.
let had_click = out[idx[0]].0.start == AnimStart::OnClick;
let mut order: Vec<usize> = (0..idx.len()).collect();
order.sort_by_key(|&k| abs[k]);
let mut sorted: Vec<(Animation, u32, String, bool)> = order.iter().map(|&k| out[idx[k]].clone()).collect();
let (mut begin, mut end) = (0u32, 0u32);
for (n, (&k, item)) in order.iter().zip(sorted.iter_mut()).enumerate() {
let a = &mut item.0;
let t = abs[k];
if n == 0 && had_click {
a.start = AnimStart::OnClick;
a.delay_ms = t;
} else if a.start == AnimStart::AfterPrevious && t >= end {
begin = end;
a.delay_ms = t - end;
} else {
a.start = AnimStart::WithPrevious;
a.delay_ms = t.saturating_sub(begin);
}
end = end.max(t.saturating_add(span_ms(a)));
}
for (&i, item) in idx.iter().zip(sorted) {
out[i] = item;
}
collect(c, trig, out, depth + 1);
}
}
@@ -135,7 +238,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
let _ = (imp, part);
let Some(tn) = timing.child("tnLst") else { return vec![] };
let mut found = vec![];
collect(tn, None, &mut found, 0);
collect(tn, At { trigger: None, group: 0, offset: 0 }, &mut 0, &mut found, 0);
// Paragraph builds (`bldP build="p"`) by (spid, grpId).
let by_para: Vec<(u32, String)> = timing
.child("bldLst")
@@ -147,6 +250,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
})
.unwrap_or_default();
let mut out: Vec<(Animation, u32, String, bool)> = vec![];
let mut at: Vec<(Option<u32>, usize, u32)> = vec![];
for f in found {
let c = f.ctn;
let class = AnimClass::from_xml(c.attr("presetClass").unwrap_or("entr"));
@@ -212,7 +316,9 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
let grp = c.attr("grpId").unwrap_or("0").to_string();
let build = paragraph.is_some() && by_para.iter().any(|(s, g)| *s == file_id && *g == grp);
out.push((a, file_id, grp, build));
at.push((f.trigger, f.group, f.offset));
}
place_by_parent_delays(&mut out, &at);
// A paragraph build's effects, one per paragraph, become one by-paragraph animation only when
// the timeline plays that back exactly: PowerPoint lets each paragraph keep its own start and
// effect, and other shapes' effects can sit between them (#844).
+13 -6
View File
@@ -210,13 +210,20 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt
let mut w = W::new();
w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"));
let names = deckcraft_model::table::table_styles();
let mut done: Vec<&str> = vec![];
// Indexes instead of scans: written ids (as given and ASCII lower-case), and the file's
// definitions by lower-case id (the first wins).
let mut done: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut done_lower: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut def_of: std::collections::HashMap<String, &deckcraft_model::table::TableStyleDef> = std::collections::HashMap::new();
for d in defs {
def_of.entry(d.id.to_ascii_lowercase()).or_insert(d);
}
for s in used {
if done.contains(&s.as_str()) {
if !done.insert(s.as_str()) {
continue;
}
done.push(s);
if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s))
done_lower.insert(s.to_ascii_lowercase());
if let Some(d) = def_of.get(&s.to_ascii_lowercase())
&& crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle"))
{
w.raw(&d.raw);
@@ -231,11 +238,11 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt
}
// Styles the file defined but no table uses still belong to the deck.
for d in defs {
if done.iter().any(|s| s.eq_ignore_ascii_case(&d.id)) {
if done_lower.contains(&d.id.to_ascii_lowercase()) {
continue;
}
if crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle")) {
done.push(&d.id);
done_lower.insert(d.id.to_ascii_lowercase());
w.raw(&d.raw);
}
}
+32 -7
View File
@@ -53,6 +53,8 @@ pub struct Exp<'a> {
pub p: &'a Presentation,
pub pkg: PackageOut,
media_parts: HashMap<MediaId, String>,
/// Each media id's item in `p.media` (the first with the id, as `Presentation::media`).
media_at: HashMap<MediaId, usize>,
n_image: u32,
n_media: u32,
n_chart: u32,
@@ -63,6 +65,8 @@ pub struct Exp<'a> {
n_kept: u32,
pub slide_parts: HashMap<SlideId, String>,
pub table_styles: Vec<String>,
/// The ids in `table_styles`, for checking membership without a scan.
pub table_style_set: std::collections::HashSet<String>,
placeholder: Option<String>,
}
@@ -119,12 +123,17 @@ fn ext_for(content_type: &str, name: &str, data: &[u8]) -> String {
if !e.is_empty() && e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric()) { e } else { "bin".into() }
}
impl Exp<'_> {
impl<'a> Exp<'a> {
/// The media item `id`, found through the index rather than a scan of every item.
pub fn media(&self, id: MediaId) -> Option<&'a deckcraft_model::MediaItem> {
let p: &'a Presentation = self.p;
self.media_at.get(&id).and_then(|&i| p.media.get(i))
}
fn media_part(&mut self, id: MediaId) -> Option<String> {
if let Some(p) = self.media_parts.get(&id) {
return Some(p.clone());
}
let m = self.p.media(id)?;
let m = self.media(id)?;
if m.data.is_empty() {
return None;
}
@@ -147,7 +156,7 @@ impl Exp<'_> {
}
/// A relationship from `o` to a media item (embedded part, or external link).
pub fn media_rel(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> {
let m = self.p.media(id)?;
let m = self.media(id)?;
if m.data.is_empty() {
let link = m.link.clone()?;
return Some(o.rels.add(kind, &link, true));
@@ -156,7 +165,7 @@ impl Exp<'_> {
Some(o.rels.add(kind, &opc::relative(&o.name, &part), false))
}
pub fn media_rel_new(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> {
let m = self.p.media(id)?;
let m = self.media(id)?;
if m.data.is_empty() {
return None;
}
@@ -489,7 +498,8 @@ fn valid_date(s: &str) -> bool {
/// A copy of the presentation with every slide pointing at a layout that exists.
fn sanitized(p: &Presentation) -> std::borrow::Cow<'_, Presentation> {
let mut need = p.masters.is_empty() || p.masters.iter().any(|m| m.layouts.is_empty());
need |= p.slides.iter().any(|s| p.layout(s.layout).is_none());
let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
need |= p.slides.iter().any(|s| !layouts.contains(&s.layout));
if !need {
return std::borrow::Cow::Borrowed(p);
}
@@ -532,6 +542,13 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
p,
pkg: PackageOut::default(),
media_parts: HashMap::new(),
media_at: {
let mut m = HashMap::new();
for (i, item) in p.media.iter().enumerate() {
m.entry(item.id).or_insert(i);
}
m
},
n_image: 0,
n_media: 0,
n_chart: 0,
@@ -541,6 +558,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
n_kept: 0,
slide_parts: HashMap::new(),
table_styles: vec![],
table_style_set: Default::default(),
placeholder: None,
};
for (i, s) in p.slides.iter().enumerate() {
@@ -959,9 +977,16 @@ fn section_slides(p: &Presentation) -> Vec<(String, Vec<usize>)> {
if out.is_empty() {
return out;
}
// Each slide's first section, found once: a section may name a slide many times.
let mut first: HashMap<SlideId, usize> = HashMap::new();
for (k, sec) in p.sections.iter().enumerate() {
for id in &sec.slides {
first.entry(*id).or_insert(k);
}
}
let mut cur = 0usize;
for (i, s) in p.slides.iter().enumerate() {
if let Some(k) = p.sections.iter().position(|sec| sec.slides.contains(&s.id))
if let Some(&k) = first.get(&s.id)
&& k >= cur
{
cur = k;
@@ -1027,7 +1052,7 @@ fn write_embedded_fonts(x: &mut Exp, o: &mut Out) -> Vec<WrittenFont> {
if let Some(rid) = seen_part.get(&id) {
return Some(rid.clone());
}
let m = x.p.media(id)?;
let m = x.media(id)?;
if m.data.is_empty() || m.data.len() > 32 * 1024 * 1024 {
return None;
}
+38 -12
View File
@@ -100,7 +100,7 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) {
// An image that lives outside the package is a linked picture: `a:blip` names it through
// `r:link`; `r:embed` may only reference a part inside the package.
let linked = x.p.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some());
let linked = x.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some());
let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o));
w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" }));
let adj = &pf.adjust;
@@ -357,10 +357,18 @@ pub fn effects(w: &mut W, fx: &Effects) {
.a("endA", pct(r.end_alpha.clamp(0.0, 1.0)))
.a("endPos", pct(r.end_pos.clamp(0.0, 1.0)))
.a("dist", emu(r.dist).max(0))
.a("dir", 5_400_000)
.a("sy", -100_000)
.a("algn", "bl")
.a("rotWithShape", "0"),
// Kept from a file as read: held to the schema's ranges so a stray value can't
// make PowerPoint repair the file (ST_PositiveFixedAngle, ST_FixedAngle,
// ST_PositiveFixedPercentage).
.o("stPos", r.st_pos.map(|v| v.clamp(0, 100_000)))
.o("dir", r.dir.map(|v| v.rem_euclid(21_600_000)))
.o("fadeDir", r.fade_dir.map(|v| v.rem_euclid(21_600_000)))
.o("sx", r.sx)
.o("sy", r.sy)
.o("kx", r.kx.map(|v| v.clamp(-5_399_999, 5_399_999)))
.o("ky", r.ky.map(|v| v.clamp(-5_399_999, 5_399_999)))
.o("algn", r.align.as_deref())
.b("rotWithShape", r.rotate_with_shape),
);
}
if let Some(r) = fx.soft_edge {
@@ -441,7 +449,7 @@ pub fn hyperlink(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, h: &Hyperlink)
return;
};
w.open(tag, attrs);
let name = x.p.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into());
let name = x.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into());
w.empty("a:snd", A::new().a("r:embed", sound_rid).a("name", name));
w.close(tag);
}
@@ -468,7 +476,10 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
.o("anchor", b.anchor.map(|a| a.xml()))
.b("anchorCtr", b.anchor_ctr)
.b("upright", b.upright)
.b("spcFirstLastPara", b.space_first_last);
.b("spcFirstLastPara", b.space_first_last)
.b("compatLnSpc", b.compat_ln_spc)
.o("vertOverflow", b.vert_overflow.map(|v| v.xml()))
.o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(b.horz_overflow));
let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()));
if warp.is_none() && b.autofit.is_none() {
w.empty("a:bodyPr", a);
@@ -543,6 +554,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_follow_text.is_some()
|| p.bullet_size.is_some()
|| p.bullet_size_points.is_some()
|| p.bullet_size_follow_text.is_some()
|| p.tabs.is_some()
|| def.is_some();
@@ -567,8 +579,10 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
color(w, c);
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");
} 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 {
w.val("a:buSzPct", pct(s.clamp(0.25, 4.0)));
}
@@ -587,7 +601,7 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
Some(Bullet::Picture { media }) => match x.media_rel(o, *media, "image") {
Some(rid) => {
w.open0("a:buBlip");
let a = if x.p.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) {
let a = if x.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) {
A::new().a("r:link", &rid)
} else {
A::new().a("r:embed", &rid)
@@ -626,6 +640,7 @@ fn typeface(w: &mut W, tag: &str, f: &Option<String>) {
pub fn rpr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, r: &RunProps) {
let a = A::new()
.o("lang", r.lang.as_deref().filter(|l| !l.is_empty()))
.o("altLang", r.alt_lang.as_deref().filter(|l| !l.is_empty()))
.o("sz", r.size.map(|s| (s.clamp(1.0, 4000.0) * 100.0).round() as i64))
.b("b", r.bold)
.b("i", r.italic)
@@ -649,7 +664,8 @@ pub fn rpr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, r: &RunProps) {
)
.o("spc", r.spacing.map(|s| (s.clamp(-4000.0, 4000.0) * 100.0).round() as i64))
.o("baseline", r.baseline.map(|b| pct(b.clamp(-10.0, 10.0))))
.b("noProof", r.no_proof);
.b("noProof", r.no_proof)
.b("normalizeH", r.normalize_height);
let has_fx = r.shadow.is_some() || r.inner_shadow.is_some() || r.glow.is_some() || r.reflection.is_some() || r.soft_edge.is_some();
let has_kids = r.outline.is_some()
|| r.fill.is_some()
@@ -746,17 +762,27 @@ pub fn text_body(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, tb: &TextBody,
}
fn run_text(w: &mut W, x: &mut Exp, o: &mut Out, text: &str, props: &RunProps) {
// The run's properties are the same for every piece and line break: serialize them once.
let mut rpr_xml: Option<String> = None;
let mut put_rpr = |w: &mut W| match &rpr_xml {
Some(xml) => w.s.push_str(xml),
None => {
let start = w.s.len();
rpr(w, x, o, "a:rPr", props);
rpr_xml = Some(w.s[start..].to_string());
}
};
for (i, piece) in text.split(['\n', '\u{b}']).enumerate() {
if i > 0 {
w.open0("a:br");
rpr(w, x, o, "a:rPr", props);
put_rpr(w);
w.close("a:br");
}
if piece.is_empty() && i > 0 {
continue;
}
w.open0("a:r");
rpr(w, x, o, "a:rPr", props);
put_rpr(w);
w.elt("a:t", piece);
w.close("a:r");
}
+8 -5
View File
@@ -446,7 +446,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
}
fn model_rel(x: &mut Exp, o: &mut Out, media: deckcraft_model::MediaId) -> Option<String> {
if x.p.media(media).is_none_or(|m| m.data.is_empty()) {
if x.media(media).is_none_or(|m| m.data.is_empty()) {
return None;
}
x.media_rel(o, media, crate::opc::RT_MODEL3D)
@@ -479,7 +479,7 @@ fn write_model(
return;
};
let id = cx.id_for(s);
let poster = poster.filter(|p| x.p.media(*p).is_some_and(|m| !m.data.is_empty()));
let poster = poster.filter(|p| x.media(*p).is_some_and(|m| !m.data.is_empty()));
let Some(poster) = poster else {
write_model_part(w, x, o, id, s, &rid);
return;
@@ -700,7 +700,7 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
t.normalize();
w.open0("a:tbl");
let (guid, _) = crate::tables::guid_for_style(&t.style);
if !x.table_styles.contains(&t.style) {
if x.table_style_set.insert(t.style.clone()) {
x.table_styles.push(t.style.clone());
}
w.open(
@@ -711,7 +711,8 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
.t("lastRow", t.last_row)
.t("lastCol", t.last_col)
.t("bandRow", t.band_row)
.t("bandCol", t.band_col),
.t("bandCol", t.band_col)
.b("rtl", t.rtl),
);
if let Some(f) = &t.background {
dml::fill(w, x, o, f);
@@ -746,6 +747,8 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
other => other.xml(),
}),
);
a = a.b("anchorCtr", c.anchor_ctr);
a = a.o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(c.horz_overflow));
w.open("a:tcPr", a);
for (tag, l) in [
("a:lnL", &c.borders[0]),
@@ -881,7 +884,7 @@ fn opaque(
p.name = label.to_string();
}
match preview {
Some(m) if x.p.media(m).is_some() => {
Some(m) if x.media(m).is_some() => {
p.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media: m, ..Default::default() } };
p.ph = None;
shape_with_id(w, x, o, cx, &p, id);
+42 -4
View File
@@ -1,7 +1,7 @@
//! Transitions and the animation timing tree.
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};
use deckcraft_model::{Shape, ShapeId, ShapeKind, find_shape};
use deckcraft_model::{Shape, ShapeId, ShapeKind};
use super::shapes::IdMap;
use super::{Exp, Out};
@@ -16,7 +16,7 @@ pub fn transition(w: &mut W, x: &mut Exp, o: &mut Out, t: &Transition) {
601..=900 => "med",
_ => "slow",
};
let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.p.media(m).map(|i| i.name.clone()).unwrap_or_default())));
let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.media(m).map(|i| i.name.clone()).unwrap_or_default())));
let snd = |w: &mut W| {
if let Some((r, name)) = &sound {
w.open0("p:sndAc");
@@ -577,6 +577,21 @@ fn click_pars(w: &mut W, x: &mut Exp, ids: &mut Ids, clicks: &Groups, interactiv
}
}
/// Most effects one slide's timing writes; animations past it are left out.
const MAX_EFFECTS: usize = 100_000;
/// The shapes of the tree by id, the first in document order for a repeated id (as `find_shape`
/// finds it), so each animation resolves its shape without a tree walk.
fn shape_index<'a>(shapes: &'a [Shape], depth: usize, out: &mut std::collections::HashMap<ShapeId, &'a Shape>) {
if depth > 64 {
return;
}
for s in shapes {
out.entry(s.id).or_insert(s);
shape_index(s.children(), depth + 1, out);
}
}
/// Write `p:timing` for a slide's animations.
pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idmap: &IdMap) {
let mut grp_count: std::collections::HashMap<u32, u32> = Default::default();
@@ -585,16 +600,31 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
let mut bld: Vec<(u32, u32, bool)> = vec![];
// The last paragraph effect written: (shape, class, trigger, grpId).
let mut prev_para: Option<(ShapeId, AnimClass, Option<ShapeId>, u32)> = None;
let mut by_id = std::collections::HashMap::new();
shape_index(shapes, 0, &mut by_id);
let mut roles_of: std::collections::HashMap<ShapeId, Vec<BuildRole>> = Default::default();
let mut effect_count = 0usize;
let mut warned = false;
for a in anims.iter().take(10_000) {
if effect_count >= MAX_EFFECTS {
if !warned {
log::warn!("pptx: more than {MAX_EFFECTS} animation effects; the rest are left out");
}
break;
}
let Some(spid) = idmap.get(a.shape) else {
log::warn!("pptx: animation on missing shape {} skipped", a.shape);
continue;
};
let Some(shape) = find_shape(shapes, a.shape) else { continue };
let Some(&shape) = by_id.get(&a.shape) else { continue };
let text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some();
let is_text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.as_ref().is_some_and(|t| !t.is_empty());
let text = shape.text.as_ref().filter(|_| a.text_build == TextBuild::ByParagraph && is_text_sp);
let roles = text.map(paragraph_build_roles).unwrap_or_default();
let no_roles = Vec::new();
let roles = match text {
Some(t) => roles_of.entry(a.shape).or_insert_with(|| paragraph_build_roles(t)),
None => &no_roles,
};
let by_para = roles.contains(&BuildRole::Top);
// PowerPoint keeps a shape's consecutive paragraph effects in one build (`grpId`) marked
// `build="p"`; the reader turns them back into a by-paragraph build only if that plays them
@@ -632,11 +662,19 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
_ => continue,
};
let Ok(pi) = u32::try_from(i) else { break };
if effect_count + effs.len() >= MAX_EFFECTS {
if !warned {
log::warn!("pptx: more than {MAX_EFFECTS} animation effects; the rest are left out");
warned = true;
}
break;
}
effs.push(Eff { a, spid, start, paragraph: Some(pi), grp });
}
} else {
effs.push(Eff { a, spid, start: a.start, paragraph: a.paragraph, grp });
}
effect_count += effs.len();
if text_sp && !joins {
bld.push((spid, grp, by_para || para_effect));
}
+27
View File
@@ -146,6 +146,33 @@ fn comments_on_several_slides_round_trip() {
assert_eq!(q.slides[2].comments[0].text, "Slide three");
}
/// A slide that names a modern part another slide already owns, plus its own legacy part, must
/// not import the legacy copy: modern precedence holds even when the modern part is not re-read.
#[test]
fn owned_modern_part_still_suppresses_later_legacy() {
let mut p = deck();
let layout = p.slides[0].layout;
let s2 = defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s2));
Arc::make_mut(&mut p.slides[1]).comments = vec![Comment { author: "Cara".into(), text: "Slide two".into(), ..Default::default() }];
let bytes = deckcraft_pptx::export(&p).expect("export");
let rels2 = part(&bytes, "ppt/slides/_rels/slide2.xml.rels").replace("modernComment2.xml", "modernComment1.xml");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");
if f.name() == "ppt/slides/_rels/slide2.xml.rels" {
std::io::Write::write_all(&mut out, rels2.as_bytes()).expect("write");
} else {
std::io::copy(&mut f, &mut out).expect("copy");
}
}
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
assert_eq!(q.slides[0].comments.len(), 2);
assert!(q.slides[1].comments.is_empty(), "{:?}", q.slides[1].comments);
}
/// A legacy-only package (what older tools read and write) still imports: 1/8 pt positions,
/// flattened replies.
#[test]
+1 -1
View File
@@ -34,7 +34,7 @@ fn glow() -> Glow {
}
fn reflection() -> Reflection {
Reflection { blur: 2.0, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.75, dist: 6.0 }
Reflection { blur: 2.0, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.75, dist: 6.0, ..Default::default() }
}
fn gradient(angle: f64) -> Fill {
+17 -2
View File
@@ -103,7 +103,7 @@ fn effects_and_links() -> Vec<u8> {
"Reflected",
"",
8636000,
r#"<a:reflection blurRad="6350" stA="50000" endA="300" endPos="55500" dist="50800" dir="5400000" sy="-100000" algn="bl" rotWithShape="0"/>"#,
r#"<a:reflection blurRad="6350" stA="50000" endA="300" endPos="55500" dist="50800" stPos="20000" dir="5400000" fadeDir="0" sx="75000" sy="-50000" kx="600000" ky="-300000" algn="bl" rotWithShape="0"/>"#,
r#"<a:p><a:r><a:rPr lang="en-US"><a:hlinkMouseOver r:id="rId90" tooltip="Preview"/></a:rPr><a:t>Hover me</a:t></a:r></a:p>"#,
);
let slide = format!(
@@ -134,6 +134,11 @@ fn effects_and_links_survive_a_round_trip() {
assert_eq!(fx(get("Soft edged")).soft_edge, Some(5.0));
let r = fx(get("Reflected")).reflection.expect("reflection");
assert!((r.end_pos - 0.555).abs() < 1e-6 && (r.dist - 4.0).abs() < 1e-6, "{r:?}");
assert_eq!(
(r.st_pos, r.dir, r.fade_dir, r.sx, r.sy, r.kx, r.ky),
(Some(20_000), Some(5_400_000), Some(0), Some(75_000), Some(-50_000), Some(600_000), Some(-300_000))
);
assert_eq!((r.align.as_deref(), r.rotate_with_shape), (Some("bl"), Some(false)));
let shape = get("Shadowed");
assert!(matches!(shape.click.as_ref().map(|h| (&h.action, h.highlight_click)), Some((Action::EndShow, true))));
assert!(
@@ -173,7 +178,17 @@ fn effects_and_links_survive_resave(a: &Presentation) {
assert_eq!(ha, hb, "text hover of {n}");
}
let out = deckcraft_pptx::export(&b).expect("export");
assert!(part(&out, "ppt/slides/slide1.xml").contains("hlinkMouseOver"));
let xml = part(&out, "ppt/slides/slide1.xml");
assert!(xml.contains("hlinkMouseOver"));
let c = deckcraft_pptx::import(&out).expect("second reopen");
assert_eq!(fx(shapes_named(&b, 0)("Reflected")).reflection, fx(shapes_named(&c, 0)("Reflected")).reflection);
assert!(
xml.contains("stPos=\"20000\"")
&& xml.contains("fadeDir=\"0\"")
&& xml.contains("sx=\"75000\"")
&& xml.contains("sy=\"-50000\"")
&& xml.contains("kx=\"600000\"")
);
}
/// Two masters with their own themes (colours, fonts) and layouts, slides on each.
+31
View File
@@ -0,0 +1,31 @@
//! Export looks media, layouts, sections and table styles up by id: a deck with many of each and
//! many references to the last ones exports in time linear in its size.
use std::sync::Arc;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{Section, Shape, ShapeId, ShapeKind, defaults};
#[test]
fn many_media_sections_and_table_styles_export() {
let mut p = defaults::new_presentation(None);
// 5,000 media items; 5,000 pictures of the last one.
let mut last = None;
for i in 0..5_000u32 {
last = p.add_media(&format!("m{i}.png"), "image/png", i.to_le_bytes().to_vec());
}
let last = last.unwrap();
let pic = |k: u32| Shape {
id: ShapeId(100_000 + k),
kind: ShapeKind::Picture { fill: PictureFill { media: last, ..Default::default() } },
..Default::default()
};
Arc::make_mut(&mut p.slides[0]).shapes = (0..5_000).map(pic).collect();
// A section naming the slide 50,000 times, after 2,000 empty sections.
let id = p.slides[0].id;
p.sections = (0..2_000).map(|i| Section { name: format!("s{i}"), slides: vec![] }).collect();
p.sections.push(Section { name: "last".into(), slides: vec![id; 50_000] });
let bytes = deckcraft_pptx::export(&p).expect("export");
let q = deckcraft_pptx::import(&bytes).expect("import");
assert_eq!(q.slides[0].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Picture { .. })).count(), 5_000);
}
+2
View File
@@ -9,6 +9,7 @@ mod corpus_kinds;
mod corpus_media;
mod corpus_style;
mod corpus_themes;
mod export_scale;
mod fills;
mod kept_parts;
mod locks;
@@ -18,6 +19,7 @@ mod media_timing;
mod notes;
mod ole;
mod ole_vml;
mod parent_delays;
mod placeholders;
mod roundtrip;
mod sample;
+31
View File
@@ -287,3 +287,34 @@ fn unreadable_slide_is_reported() {
assert_eq!(lost, vec!["ppt/slides/slide2.xml".to_string()]);
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
}
/// Many slides naming one notes part: it is read for the first slide only, not copied onto every
/// slide that names it.
#[test]
fn a_shared_notes_part_is_read_once() {
use std::sync::Arc;
let mut p = defaults::new_presentation(None);
let layout = p.slides[0].layout;
for _ in 0..3 {
let s = defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s));
}
for (i, s) in p.slides.iter_mut().enumerate() {
Arc::make_mut(s).notes = deckcraft_model::text::TextBody::from_text(&format!("notes {i}"));
}
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
for (n, d) in es.iter_mut() {
if n.starts_with("ppt/slides/_rels/") {
let x = String::from_utf8(d.clone()).expect("utf8");
let x = x
.replace("notesSlide2.xml", "notesSlide1.xml")
.replace("notesSlide3.xml", "notesSlide1.xml")
.replace("notesSlide4.xml", "notesSlide1.xml");
*d = x.into_bytes();
}
}
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
assert_eq!(q.slides.len(), 4);
assert_eq!(q.slides[0].notes.text(), "notes 0");
assert!(q.slides[1..].iter().all(|s| s.notes.text().is_empty()), "a shared notes part is not copied");
}
+145
View File
@@ -0,0 +1,145 @@
//! PowerPoint places an effect at the sum of its enclosing time nodes' delays, so a file can list
//! effects out of time order. The BNI meeting-deck generator writes a 1 s countdown chain first,
//! then "swap to the visual at 5 s" effects placed only by their parent `p:par` delay: read in file
//! order, those all fired together when the countdown ended.
use std::io::{Cursor, Read, Write};
use deckcraft_geom::Xfrm;
use deckcraft_model::anim::AnimStart;
use deckcraft_model::{Shape, ShapeId, defaults};
/// An "appear"/"disappear" effect (preset 1) in its own `p:par`, as PowerPoint writes it.
fn eff(id: u32, class: &str, node: &str, delay: u32, spid: u32) -> String {
let vis = if class == "entr" { "visible" } else { "hidden" };
format!(
r#"<p:par><p:cTn id="{id}" presetID="1" presetClass="{class}" presetSubtype="0" fill="hold" nodeType="{node}"><p:stCondLst><p:cond delay="{delay}"/></p:stCondLst><p:childTnLst><p:set><p:cBhvr><p:cTn id="{}" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="{spid}"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="{vis}"/></p:to></p:set></p:childTnLst></p:cTn></p:par>"#,
id + 1000
)
}
/// A group `p:par` starting `delay` ms into the click, holding `inner`.
fn group(id: u32, delay: u32, inner: &str) -> String {
format!(
r#"<p:par><p:cTn id="{id}" fill="hold"><p:stCondLst><p:cond delay="{delay}"/></p:stCondLst><p:childTnLst>{inner}</p:childTnLst></p:cTn></p:par>"#
)
}
fn import_with_timing(build: impl Fn(&[u32]) -> String) -> Vec<(String, AnimStart, u32)> {
let mut p = defaults::new_presentation(None);
let shapes: Vec<Shape> = ["Three", "Two", "One", "Visual"]
.into_iter()
.map(|name| Shape {
id: ShapeId(p.alloc_id().expect("id")),
name: name.into(),
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 50.0)),
..Default::default()
})
.collect();
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = shapes;
let mut z = zip::ZipArchive::new(Cursor::new(deckcraft_pptx::export(&p).expect("export"))).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let name = f.name().to_string();
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
if name == "ppt/slides/slide1.xml" {
let xml = String::from_utf8(d).expect("utf8");
let spids: Vec<u32> = ["Three", "Two", "One", "Visual"]
.iter()
.map(|n| {
let at = xml.find(&format!(r#"name="{n}""#)).expect("shape written");
let open = xml[..at].rfind("<p:cNvPr ").expect("cNvPr");
let tag = &xml[open..at];
let id = tag.split(r#"id=""#).nth(1).expect("id").split('"').next().expect("id");
id.parse().expect("number")
})
.collect();
let timing = format!(
r#"<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst><p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst><p:par><p:cTn id="3" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst>{}</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn><p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst><p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst></p:seq></p:childTnLst></p:cTn></p:par></p:tnLst></p:timing>"#,
build(&spids)
);
let xml = match (xml.find("<p:timing>"), xml.find("</p:timing>")) {
(Some(a), Some(b)) => format!("{}{}{}", &xml[..a], timing, &xml[b + "</p:timing>".len()..]),
_ => xml.replacen("</p:sld>", &format!("{timing}</p:sld>"), 1),
};
d = xml.into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
out.write_all(&d).expect("write");
}
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
let s = &q.slides[0];
s.animations
.iter()
.map(|a| {
let name = s.shapes.iter().find(|x| x.id == a.shape).map(|x| x.name.clone()).unwrap_or_default();
(name, a.start, a.delay_ms)
})
.collect()
}
/// Start (ms after the click) of each effect when the flat list is played effect after effect.
fn flat(effects: &[(String, AnimStart, u32)]) -> Vec<(String, u32)> {
let (mut begin, mut end) = (0u32, 0u32);
effects
.iter()
.map(|(n, start, delay)| {
if *start == AnimStart::AfterPrevious {
begin = end;
}
let t = begin + delay;
end = end.max(t);
(n.clone(), t)
})
.collect()
}
#[test]
fn effects_placed_by_parent_delay_play_when_powerpoint_plays_them() {
// Click: "Three" goes, then "Two" after 1 s, "One" after 1 s more (a countdown chain), then —
// written last — the visual appears at 1 s, placed only by its parent's delay.
let fx = import_with_timing(|s| {
[
group(10, 0, &eff(11, "exit", "clickEffect", 0, s[0])),
group(20, 0, &eff(21, "exit", "afterEffect", 1000, s[1])),
group(30, 1000, &eff(31, "exit", "afterEffect", 1000, s[2])),
group(40, 1000, &eff(41, "entr", "afterEffect", 0, s[3])),
]
.concat()
});
let got = flat(&fx);
let at = |n: &str| got.iter().find(|(x, _)| x == n).map(|(_, t)| *t);
assert_eq!((at("Three"), at("Two"), at("Visual"), at("One")), (Some(0), Some(1000), Some(1000), Some(2000)), "{fx:?}");
}
#[test]
fn a_timeline_already_in_order_is_read_unchanged() {
let fx = import_with_timing(|s| {
[
group(10, 0, &[eff(11, "exit", "clickEffect", 0, s[0]), eff(12, "entr", "withEffect", 500, s[3])].concat()),
group(20, 500, &eff(21, "exit", "afterEffect", 1000, s[1])),
]
.concat()
});
let starts: Vec<_> = fx.iter().map(|(n, st, d)| (n.as_str(), *st, *d)).collect();
assert_eq!(starts, [("Three", AnimStart::OnClick, 0), ("Visual", AnimStart::WithPrevious, 500), ("Two", AnimStart::AfterPrevious, 1000)]);
}
#[test]
fn a_delayed_click_effect_sorted_after_a_with_effect_keeps_the_click_on_the_group() {
// The click effect waits 500 ms; a with-effect in the same click plays at once, and a later
// effect is placed only by its parent's delay. Sorted, the with-effect plays first and takes
// the click, so the group still waits for one.
let fx = import_with_timing(|s| {
[
group(10, 0, &[eff(11, "exit", "clickEffect", 500, s[0]), eff(12, "entr", "withEffect", 0, s[3])].concat()),
group(20, 1000, &eff(21, "exit", "afterEffect", 0, s[1])),
]
.concat()
});
let starts: Vec<_> = fx.iter().map(|(n, st, _)| (n.as_str(), *st)).collect();
assert_eq!(starts[0], ("Visual", AnimStart::OnClick), "{fx:?}");
assert_eq!(starts.iter().filter(|(_, st)| *st == AnimStart::OnClick).count(), 1, "{fx:?}");
}
+214 -1
View File
@@ -12,7 +12,7 @@ use deckcraft_model::style::{
};
use deckcraft_model::table::Table;
use deckcraft_model::text::{
Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, Paragraph, Run, RunKind, RunProps, Spacing, Strike, TextBody,
Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, Paragraph, Run, RunKind, RunProps, Spacing, Strike, TextBody, TextOverflow,
};
use deckcraft_model::{
Background, Comment, CustomPath, CustomShow, Geom, InkStroke, MediaClip, Presentation, Section, Shape, ShapeId, ShapeKind, Slide, SlideId,
@@ -468,6 +468,143 @@ fn text_and_formatting_round_trip() {
assert_eq!(run.text, "x=1");
}
#[test]
fn centered_cell_anchor_survives_two_pptx_round_trips() {
let mut p = Presentation::default();
let mut slide = new_slide(&mut p, "Cell anchor");
let mut table_shape = shape(&mut p, "Table", 10.0, 10.0, 200.0, 80.0);
let mut table = Table::new(1, 1, 200.0, 80.0);
table.rows[0].cells[0].anchor_ctr = Some(true);
table_shape.kind = ShapeKind::Table(table);
slide.shapes.push(table_shape);
p.slides.push(Arc::new(slide));
let once = round(&p);
let twice = round(&once);
let slide = twice.slides.last().expect("added slide");
let ShapeKind::Table(table) = &by_name(slide, "Table").kind else { panic!("table") };
assert_eq!(table.rows[0].cells[0].anchor_ctr, Some(true));
}
#[test]
fn normalized_character_height_survives_two_pptx_round_trips() {
let mut p = Presentation::default();
let mut slide = new_slide(&mut p, "Normalized height");
let mut enabled = shape(&mut p, "Enabled", 10.0, 10.0, 200.0, 80.0);
let mut enabled_body = TextBody::from_text("enabled");
enabled_body.paragraphs[0].runs[0].props.normalize_height = Some(true);
enabled_body.paragraphs[0].runs[0].props.size = Some(18.0);
enabled_body.paragraphs[0].runs[0].props.caps = Some(Caps::Small);
enabled_body.paragraphs[0].runs[0].props.spacing = Some(1.5);
enabled.text = Some(enabled_body);
let mut disabled = shape(&mut p, "Disabled", 10.0, 100.0, 200.0, 80.0);
let mut disabled_body = TextBody::from_text("disabled");
disabled_body.paragraphs[0].runs[0].props.normalize_height = Some(false);
disabled.text = Some(disabled_body);
let mut absent = shape(&mut p, "Absent", 10.0, 190.0, 200.0, 80.0);
absent.text = Some(TextBody::from_text("absent"));
slide.shapes = vec![enabled, disabled, absent];
p.slides.push(Arc::new(slide));
let twice = round(&round(&p));
let slide = twice.slides.last().expect("added slide");
let run = &by_name(slide, "Enabled").text.as_ref().expect("text").paragraphs[0].runs[0];
assert_eq!(run.props.normalize_height, Some(true));
assert_eq!(run.props.size, Some(18.0));
assert_eq!(run.props.caps, Some(Caps::Small));
assert_eq!(run.props.spacing, Some(1.5));
let run = &by_name(slide, "Disabled").text.as_ref().expect("text").paragraphs[0].runs[0];
assert_eq!(run.props.normalize_height, Some(false));
let run = &by_name(slide, "Absent").text.as_ref().expect("text").paragraphs[0].runs[0];
assert_eq!(run.props.normalize_height, None);
}
#[test]
fn thai_distributed_alignment_survives_two_pptx_round_trips() {
assert_eq!(Align::from_xml("thaiDist"), Some(Align::ThaiDistributed));
assert_eq!(Align::ThaiDistributed.xml(), "thaiDist");
assert_eq!(Align::from_xml("just"), Some(Align::Justify));
let mut p = Presentation::default();
let mut slide = new_slide(&mut p, "Thai distributed alignment");
let mut text = shape(&mut p, "Text", 10.0, 10.0, 200.0, 80.0);
let mut body = TextBody::from_text("one two three");
body.paragraphs[0].props.align = Some(Align::ThaiDistributed);
text.text = Some(body);
slide.shapes.push(text);
p.slides.push(Arc::new(slide));
let twice = round(&round(&p));
let slide = twice.slides.last().expect("added slide");
let text = by_name(slide, "Text").text.as_ref().expect("text body");
assert_eq!(text.paragraphs[0].props.align, Some(Align::ThaiDistributed));
}
#[test]
fn compatible_line_spacing_mode_survives_two_pptx_round_trips() {
let mut p = Presentation::default();
let mut slide = new_slide(&mut p, "Compatible line spacing");
let mut enabled = shape(&mut p, "Enabled", 10.0, 10.0, 200.0, 80.0);
let mut enabled_body = TextBody::from_text("enabled");
enabled_body.body.compat_ln_spc = Some(true);
enabled.text = Some(enabled_body);
let mut disabled = shape(&mut p, "Disabled", 10.0, 100.0, 200.0, 80.0);
let mut disabled_body = TextBody::from_text("disabled");
disabled_body.body.compat_ln_spc = Some(false);
disabled.text = Some(disabled_body);
slide.shapes = vec![enabled, disabled];
p.slides.push(Arc::new(slide));
let twice = round(&round(&p));
let slide = twice.slides.last().expect("added slide");
assert_eq!(by_name(slide, "Enabled").text.as_ref().expect("text").body.compat_ln_spc, Some(true));
assert_eq!(by_name(slide, "Disabled").text.as_ref().expect("text").body.compat_ln_spc, Some(false));
}
#[test]
fn text_overflow_policies_survive_two_pptx_round_trips() {
let mut p = Presentation::default();
let mut slide = new_slide(&mut p, "Text overflow");
let mut text = shape(&mut p, "Text", 10.0, 10.0, 200.0, 80.0);
let mut body = TextBody::from_text("policy");
body.body.vert_overflow = Some(TextOverflow::Clip);
body.body.horz_overflow = Some(TextOverflow::Overflow);
text.text = Some(body);
let mut table_shape = shape(&mut p, "Table", 10.0, 100.0, 200.0, 80.0);
let mut table = Table::new(1, 1, 200.0, 80.0);
table.rows[0].cells[0].horz_overflow = Some(TextOverflow::Clip);
table_shape.kind = ShapeKind::Table(table);
slide.shapes = vec![text, table_shape];
p.slides.push(Arc::new(slide));
let twice = round(&round(&p));
let slide = twice.slides.last().expect("added slide");
let text = by_name(slide, "Text").text.as_ref().expect("text body");
assert_eq!(text.body.vert_overflow, Some(TextOverflow::Clip));
assert_eq!(text.body.horz_overflow, Some(TextOverflow::Overflow));
let ShapeKind::Table(table) = &by_name(slide, "Table").kind else { panic!("table") };
assert_eq!(table.rows[0].cells[0].horz_overflow, Some(TextOverflow::Clip));
}
#[test]
fn horizontal_ellipsis_is_not_written_because_only_vertical_overflow_has_it() {
let mut p = Presentation::default();
let mut slide = new_slide(&mut p, "Horizontal ellipsis");
let mut text = shape(&mut p, "Text", 10.0, 10.0, 200.0, 80.0);
let mut body = TextBody::from_text("policy");
body.body.vert_overflow = Some(TextOverflow::Ellipsis);
body.body.horz_overflow = Some(TextOverflow::Ellipsis);
text.text = Some(body);
slide.shapes = vec![text];
p.slides.push(Arc::new(slide));
let once = round(&p);
let slide = once.slides.last().expect("added slide");
let text = by_name(slide, "Text").text.as_ref().expect("text body");
assert_eq!(text.body.vert_overflow, Some(TextOverflow::Ellipsis));
assert_eq!(text.body.horz_overflow, None);
}
#[test]
fn shapes_fills_lines_effects_round_trip() {
let p = rich_deck();
@@ -490,6 +627,33 @@ fn shapes_fills_lines_effects_round_trip() {
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]
fn line_miter_limit_survives_two_pptx_round_trips() {
let mut p = Presentation::default();
@@ -1500,6 +1664,32 @@ fn literal_line_ends_in_run_text_read_as_breaks_and_survive_save() {
assert_eq!(runs_of(&round(&a), "one"), want, "save and reopen keep the runs");
}
#[test]
fn alternate_run_language_survives_pptx_saves_and_absence() {
let mut p = defaults::new_presentation(None);
let first = p.slides[0].id;
let slide = p.slide_mut(first).expect("slide");
slide.shapes[0].text = Some(TextBody {
paragraphs: vec![Paragraph {
runs: vec![
Run::with("bonjour", RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() }),
Run::with(" plain", RunProps { lang: Some("en-US".into()), ..Default::default() }),
],
..Default::default()
}],
..Default::default()
});
let mut q = round(&p);
for _ in 0..2 {
let runs = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].runs;
assert_eq!(runs[0].props.lang.as_deref(), Some("en-US"));
assert_eq!(runs[0].props.alt_lang.as_deref(), Some("fr-FR"));
assert_eq!(runs[1].props.lang.as_deref(), Some("en-US"));
assert_eq!(runs[1].props.alt_lang, None);
q = round(&q);
}
}
#[test]
fn color_pulse_keeps_its_effect_colour_and_duration() {
let mut p = defaults::new_presentation(None);
@@ -2067,3 +2257,26 @@ fn default_durations_match_powerpoint() {
assert_eq!(a.4, ms, "{effect}");
}
}
#[test]
fn reflection_angles_and_skews_out_of_range_are_written_inside_the_schema() {
let mut p = Presentation::default();
let mut slide = new_slide(&mut p, "Reflection ranges");
let mut s = shape(&mut p, "Reflected", 10.0, 10.0, 200.0, 80.0);
s.effects = Some(deckcraft_model::style::Effects {
reflection: Some(deckcraft_model::style::Reflection {
st_pos: Some(150_000),
dir: Some(-5),
fade_dir: Some(21_600_000),
kx: Some(9_000_000),
ky: Some(-9_000_000),
..Default::default()
}),
..Default::default()
});
slide.shapes.push(s);
p.slides.push(Arc::new(slide));
let r = round(&p).slides.last().and_then(|sl| by_name(sl, "Reflected").effects.clone()).and_then(|fx| fx.reflection).expect("reflection");
assert_eq!((r.st_pos, r.dir, r.fade_dir, r.kx, r.ky), (Some(100_000), Some(21_599_995), Some(0), Some(5_399_999), Some(-5_399_999)));
}
+108
View File
@@ -36,3 +36,111 @@ fn tags_survive_round_trip() {
assert_eq!(first.tags, vec![t("THINKCELLSHAPEDONOTDELETE", "t!abc")]);
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");
}
+25 -8
View File
@@ -1,5 +1,7 @@
//! Charts: title, plot area with axes and gridlines, series marks, legend.
use std::collections::HashMap;
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::Shape;
use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale};
@@ -85,9 +87,9 @@ fn bar_outline(ctx: &Ctx, s: &deckcraft_model::chart::Series) -> Option<(Rgba, f
/// Paint of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
/// (not waterfalls, which are never read from a `<c:barChart>`). Pattern fills draw as patterns and
/// `a:noFill` leaves the bar empty, as in PowerPoint.
fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Paint {
if let Some((_, f)) = s.point_fills.iter().find(|(k, _)| *k == pi) {
return paint_of(ctx, Some(f), series_fill(ctx, c, si, Some(s)));
fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series, fills: Option<&HashMap<usize, &Fill>>) -> Paint {
if let Some(f) = fills.and_then(|m| m.get(&pi)) {
return paint_of(ctx, Some(*f), series_fill(ctx, c, si, Some(s)));
}
if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall {
return Paint::Color(series_color(ctx, c, pi));
@@ -883,6 +885,8 @@ fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) {
let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) };
let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values));
let mut pts = vec![];
// Whether the series has any x value, found once rather than per point.
let any_x = s.x_values.iter().flatten().next().is_some();
for i in 0..ncat {
let Some(v) = s.values.get(i).copied().flatten() else { continue };
let v = if stacked {
@@ -896,11 +900,11 @@ fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) {
};
let x = match kind {
ChartType::Scatter | ChartType::Bubble => {
match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) {
match (s.x_values.get(i).copied().flatten().and_then(to_x), any_x) {
(Some(x), _) => x,
// No x values at all: the points are numbered 1, 2, 3...
(None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)),
(None, Some(_)) => continue,
(None, false) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)),
(None, true) => continue,
}
}
_ => plot.x0 + band * (slot(i) + 0.5),
@@ -970,6 +974,19 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
let group = bar + step * (n - 1.0);
// Error bars need each series' mean and spread: worked out once, not per point.
let stats: Vec<deckcraft_model::chart::SeriesStats> = bars.iter().map(|(_, s)| deckcraft_model::chart::SeriesStats::of(&s.values)).collect();
// Each series' per-point fills by point (the first for a point wins), not searched per bar.
let point_fills: Vec<HashMap<usize, &Fill>> = bars
.iter()
.map(|(_, s)| {
let mut m = HashMap::new();
for (k, f) in &s.point_fills {
m.entry(*k).or_insert(f);
}
m
})
.collect();
// A waterfall's running bars, worked out once for all its categories.
let waterfall_bars = if waterfall { c.waterfall_bars() } else { Vec::new() };
for i in 0..ncat {
let mut pos_acc = 0.0;
let mut neg_acc = 0.0;
@@ -983,7 +1000,7 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total;
let start = band * slot(i) + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 };
let (a, b) = if waterfall {
c.waterfall_bars().get(i).copied().unwrap_or_default()
waterfall_bars.get(i).copied().unwrap_or_default()
} else if stacked {
if v >= 0.0 {
let r = (pos_acc, pos_acc + v);
@@ -1015,7 +1032,7 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
};
Paint::Color(series_color(rctx, c, slot))
} else {
bar_paint(rctx, c, *si, i, s)
bar_paint(rctx, c, *si, i, s, point_fills.get(bi))
};
fill_with(ctx, &paint, r.abs(), m);
if !waterfall && let Some((oc, ow)) = bar_outline(rctx, s).filter(|o| o.1 > 0.0) {
+44 -6
View File
@@ -37,7 +37,8 @@ struct Cache {
bad_order: VecDeque<usize>,
bad_bytes: usize,
/// GIFs by bytes address and adjustments (`None`: not animated); the bytes stay alive with them.
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>)>,
/// Decoded animations by (bytes, adjustments), each with the clock of its last use.
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>, u64)>,
}
/// Longest side, total decoded size and frame count kept for one animated GIF; the largest canvas
@@ -432,16 +433,33 @@ fn anim(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust) -> Option<Arc<Anim>> {
return None;
}
let key = (Arc::as_ptr(bytes) as usize, adjust_key(adj));
if let Some(a) = with(|c| c.anims.get(&key).map(|e| e.1.clone())) {
let hit = with(|c| {
c.clock += 1;
let clock = c.clock;
c.anims.get_mut(&key).map(|e| {
e.2 = clock;
e.1.clone()
})
});
if let Some(a) = hit {
return a;
}
let a = decode_anim(bytes, adj).map(Arc::new);
with(|c| {
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum();
if held + a.as_ref().map_or(0, |a| a.bytes()) > GIF_CACHE_BUDGET || c.anims.len() >= 64 {
c.anims.clear();
// Make room by dropping the least recently used animations, not every one of them, so a
// slide whose animations overflow the cache does not decode all of them again each frame.
let new = a.as_ref().map_or(0, |a| a.bytes());
loop {
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum();
if held + new <= GIF_CACHE_BUDGET && c.anims.len() < 64 {
break;
}
let Some(oldest) = c.anims.iter().min_by_key(|(_, e)| e.2).map(|(k, _)| *k) else { break };
c.anims.remove(&oldest);
}
c.anims.insert(key, (bytes.clone(), a.clone()));
c.clock += 1;
let clock = c.clock;
c.anims.insert(key, (bytes.clone(), a.clone(), clock));
});
a
}
@@ -535,6 +553,26 @@ mod tests {
out.into_inner()
}
/// A two-frame GIF whose first pixel is `n`, so each one has its own bytes.
fn gif(n: u8) -> Arc<Vec<u8>> {
use image::codecs::gif::GifEncoder;
let frame = |c: u8| image::Frame::new(image::RgbaImage::from_pixel(2, 2, image::Rgba([n, c, 0, 255])));
let mut out = Vec::new();
GifEncoder::new(&mut out).encode_frames([frame(0), frame(255)]).expect("encode");
Arc::new(out)
}
#[test]
fn a_full_animation_cache_drops_the_oldest_entry_not_all_of_them() {
let adj = PictureAdjust::default();
let gifs: Vec<_> = (0..70).map(gif).collect();
for g in &gifs {
assert!(anim(g, &adj).is_some());
}
let (len, newest) = with(|c| (c.anims.len(), c.anims.contains_key(&(Arc::as_ptr(&gifs[69]) as usize, adjust_key(&adj)))));
assert!(newest && len >= 32, "{len} cached");
}
#[test]
fn rgba_conversion_is_refused_past_the_cap() {
let png = grey_png(100, 50);
+3
View File
@@ -94,6 +94,9 @@ mod tests;
#[cfg(test)]
mod tests_wild;
#[cfg(test)]
mod tests_effect_holes;
#[cfg(test)]
mod mt_tests {
use super::*;
+22 -10
View File
@@ -76,16 +76,12 @@ fn tokens(l: &TextLayout, chars: bool) -> (Vec<Token>, Vec<(usize, usize)>) {
/// For each token of `b`, the token of `a` with the same text, in reading order, each used once.
fn matches(a: &[Token], b: &[Token]) -> Vec<Option<usize>> {
let mut used = vec![false; a.len()];
b.iter()
.map(|tb| {
let i = a.iter().enumerate().position(|(i, ta)| !used.get(i).copied().unwrap_or(true) && ta.key == tb.key)?;
if let Some(u) = used.get_mut(i) {
*u = true;
}
Some(i)
})
.collect()
// Unused tokens of `a` by text, in reading order, so each lookup is a pop rather than a scan.
let mut free: std::collections::HashMap<&str, std::collections::VecDeque<usize>> = std::collections::HashMap::new();
for (i, ta) in a.iter().enumerate() {
free.entry(ta.key.as_str()).or_default().push_back(i);
}
b.iter().map(|tb| free.get_mut(tb.key.as_str())?.pop_front()).collect()
}
fn layout(ctx: &Ctx, s: &Shape, x: Xfrm, fields: &dyn Fields) -> Option<TextLayout> {
@@ -228,3 +224,19 @@ pub(crate) fn draw(
pub(crate) fn token_keys(l: &TextLayout, chars: bool) -> Vec<String> {
tokens(l, chars).0.into_iter().map(|t| t.key).collect()
}
#[cfg(test)]
mod match_tests {
use super::*;
fn tok(k: &str) -> Token {
Token { key: k.into(), glyphs: Vec::new(), origin: Point::ZERO, run: 0 }
}
#[test]
fn each_source_token_is_used_once_in_reading_order() {
let a: Vec<Token> = ["a", "b", "a"].map(tok).into();
let b: Vec<Token> = ["a", "a", "a", "b", "c"].map(tok).into();
assert_eq!(matches(&a, &b), vec![Some(0), Some(2), None, Some(1), None]);
}
}
+35 -4
View File
@@ -91,7 +91,10 @@ pub fn fill_path(
let on_path = m * path.clone();
ctx.set_transform(Affine::IDENTITY);
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();
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));
// PowerPoint draws the tile at a whole number of output pixels.
let k = px_scale.max(1e-9);
let snap = |v: f64| (v * k).round().max(1.0) / k;
let (tile_w, tile_h) = (snap(nw * 0.75 * sx), snap(nh * 0.75 * sy));
// Per axis: a group can stretch its child space unevenly, and a pixel is then a different
// number of shape units across and down.
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()) {
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);
}
/// `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
/// 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).
+3 -1
View File
@@ -131,9 +131,11 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
}
ShapeKind::Table(t) => {
// Cell text, laid out exactly as the table draws it.
// The table's style is looked up once, not once per cell.
let def = table::resolve_style(rctx, t);
for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) {
let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue };
let body = table::cell_body(rctx, t, r, c, cell);
let body = table::cell_body_with_def(rctx, def, t, r, c, cell);
let tmp = Shape::table_cell(body.clone());
place_text(out, rctx, fields, s.id, &tmp, &body, rect, m);
}
+89 -16
View File
@@ -33,6 +33,10 @@ pub struct Renderer {
/// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget
/// of the normal drawing that follows.
shadow_work: usize,
/// `RenderOpts::skip` as a set, with the slice (address, length) it was built from: the
/// slice can be as long as the slide, and is tested for every shape drawn. Forgotten when a
/// frame starts, so another frame's slice at the same address is never mistaken for it.
skip: Option<(usize, usize, std::collections::HashSet<ShapeId>)>,
}
impl Default for Renderer {
@@ -48,6 +52,7 @@ impl Default for Renderer {
in_shadow_pass: false,
render_work: 0,
shadow_work: 0,
skip: None,
}
}
}
@@ -163,6 +168,7 @@ impl Renderer {
pub(crate) fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
@@ -178,6 +184,7 @@ impl Renderer {
pub(crate) fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
@@ -212,6 +219,7 @@ impl Renderer {
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
self.render_work = 0;
self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0));
let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) };
@@ -260,6 +268,7 @@ impl Renderer {
pub(crate) fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false;
let (w, h) = output_size(pres, opts);
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
@@ -346,6 +355,14 @@ impl Renderer {
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
/// 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.
@@ -385,8 +402,25 @@ impl Renderer {
geo
}
/// Whether `id` is in `skip`, looked up in a set built once per frame and slice.
fn skipped(&mut self, skip: &[ShapeId], id: ShapeId) -> bool {
if skip.is_empty() {
return false;
}
let key = (skip.as_ptr() as usize, skip.len());
match &self.skip {
Some((p, n, set)) if (*p, *n) == key => set.contains(&id),
_ => {
let set: std::collections::HashSet<ShapeId> = skip.iter().copied().collect();
let hit = set.contains(&id);
self.skip = Some((key.0, key.1, set));
hit
}
}
}
fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
if depth > 32 || (s.hidden && !f.opts.show_hidden) || self.skipped(f.opts.skip, s.id) {
return;
}
let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work };
@@ -428,7 +462,8 @@ impl Renderer {
// A group's own fill (a group filled with its parent's passes that on).
let own = match &s.fill {
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);
// A group's outer shadow is cast by everything in it, as PowerPoint draws it: the
@@ -541,6 +576,7 @@ impl Renderer {
geo: &Geometry,
(w, h): (f64, f64),
m: Affine,
per_point: (f64, f64),
fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade,
) {
for (i, sp) in geo.paths.iter().enumerate() {
@@ -566,7 +602,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, 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);
}
@@ -655,9 +694,13 @@ impl Renderer {
let line = &d.line;
// Effects below the shape.
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) {
(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,
};
// What casts the shadow: the fill as see-through as it is, and the outline.
@@ -736,6 +779,7 @@ impl Renderer {
let fill = &d.fill;
let outline = &d.outline;
let fade = |i: usize| d.fade(i);
let pt_xy = self.per_point_xy(m, d.depth);
// Fill.
match &s.kind {
ShapeKind::Picture { fill: pf } => {
@@ -744,13 +788,14 @@ impl Renderer {
if s.fill.as_ref().is_some_and(|f| !f.is_none())
&& let Some(fl) = &fill
{
self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade);
self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade);
}
ctx.set_transform(m);
let bounds = Rect::new(0.0, 0.0, w, h);
ctx.push_clip_layer(outline);
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();
}
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
@@ -761,7 +806,7 @@ impl Renderer {
if s.fill.as_ref().is_some_and(|f| !f.is_none())
&& let Some(fl) = &fill
{
self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade);
self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade);
}
ctx.set_transform(m);
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
@@ -787,7 +832,7 @@ impl Renderer {
if let Some(fl) = &fill
&& !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none())
{
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 +1170,15 @@ impl Renderer {
// see-through as its fill (none for an outline-only shape) …
None => {
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.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 +1209,19 @@ impl Renderer {
ctx.stroke_path(&silhouette);
// A shape without a fill (an outline, a frame) glows along its line only (#225).
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();
}
@@ -1180,7 +1240,20 @@ impl Renderer {
}
None => {
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);
}
}
}
+2 -1
View File
@@ -191,7 +191,8 @@ pub(crate) fn limit_dash_count(path: &BezPath, pat: Vec<f64>) -> Vec<f64> {
return pat;
}
let len: f64 = path.segments().map(|s| s.arclen(1e-2)).sum();
let dashes = len / period;
// Every dash of the pattern counts, not each repeat of it: a custom pattern can hold many.
let dashes = len / period * pat.len().div_ceil(2) as f64;
if !len.is_finite() {
return vec![];
}
+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]
fn placeholder_prompts_only_in_edit_view() {
let p = Presentation::default();
+198
View File
@@ -0,0 +1,198 @@
//! 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, ..Default::default() })
}
}
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],
],
);
}
+226 -1
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));
}
#[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, ..Default::default() };
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]
fn a_tiles_first_copy_sits_on_the_point_its_alignment_names() {
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
@@ -743,7 +935,7 @@ fn a_picture_reflects_its_pixels() {
let mut p = picture_deck(None);
let s = std::sync::Arc::make_mut(&mut p.slides[0]);
if let Some(sh) = s.shapes.first_mut() {
let rf = deckcraft_model::style::Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0 };
let rf = deckcraft_model::style::Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0, ..Default::default() };
sh.effects = Some(deckcraft_model::Effects { reflection: Some(rf), ..Default::default() });
}
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
@@ -798,4 +990,37 @@ fn a_tiny_dash_on_a_huge_shape_draws_without_running_out_of_memory() {
p.line_to((4.0e6, 0.0));
let pat = limit_dash_count(&p, vec![1.0, 1.0]);
assert!((4.0e6 / (pat[0] + pat[1]) - MAX_DASHES).abs() < 1.0, "{pat:?}");
// A custom pattern of 1,000 dashes counts each of them: the limit is on dashes drawn.
let pat = limit_dash_count(&p, vec![1.0; 2000]);
let period: f64 = pat.iter().sum();
assert!((4.0e6 / period * 1000.0 - MAX_DASHES).abs() < 1.0, "{}", period);
}
/// A bar chart with 20,000 points, each with its own fill, and a sparse scatter of the same size:
/// the per-point fills and the scatter's "has any x" are looked up, not searched per point.
#[test]
fn many_point_fills_and_sparse_scatter_x_render() {
use deckcraft_model::ShapeKind;
use deckcraft_model::chart::{Chart, ChartType};
use deckcraft_model::style::Fill;
let n = 20_000;
let chart = |kind: ChartType| {
let mut c = Chart::sample(kind);
c.series.truncate(1);
c.categories = (0..n).map(|i| i.to_string()).collect();
c.series[0].values = (0..n).map(|i| Some(i as f64)).collect();
c
};
let mut bars = chart(ChartType::Column);
bars.series[0].point_fills = (0..n).rev().map(|i| (i, Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0))))).collect();
let mut scatter = chart(ChartType::Scatter);
scatter.series[0].x_values = (0..n).map(|i| (i + 1 == n).then_some(1.0)).collect();
let shape = |id: u32, c: Chart| Shape {
id: ShapeId(id),
xfrm: Some(Xfrm::new(10.0, 10.0, 400.0, 300.0)),
kind: ShapeKind::Chart(Box::new(c)),
..Default::default()
};
let img = render(vec![shape(2, bars), shape(3, scatter)]);
assert!(img.width > 0);
}
+122 -8
View File
@@ -35,22 +35,30 @@ pub(crate) fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody {
t
}
/// Most path elements this thread's glyph cache retains. Its entries are shared with the font
/// cache, but they stay alive here after that cache evicts them, so they are bounded by elements.
const GLYPH_CACHE_MAX_ELEMENTS: usize = 1 << 22;
thread_local! {
static GLYPH_CACHE: std::cell::RefCell<HashMap<(u32, u32), Arc<BezPath>>> = std::cell::RefCell::new(HashMap::new());
/// The cached outlines, and the path elements they hold.
static GLYPH_CACHE: std::cell::RefCell<(HashMap<(u32, u32), Arc<BezPath>>, usize)> = std::cell::RefCell::new((HashMap::new(), 0));
}
pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id(), gid);
if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().get(&key).cloned()) {
if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().0.get(&key).cloned()) {
return p;
}
let p = FontDb::global().outline(face, gid);
GLYPH_CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.len() > 200_000 {
c.clear();
let n = p.elements().len();
if c.0.len() > 200_000 || c.1.saturating_add(n) > GLYPH_CACHE_MAX_ELEMENTS {
c.0.clear();
c.1 = 0;
}
c.insert(key, p.clone());
c.1 += n;
c.0.insert(key, p.clone());
});
p
}
@@ -105,6 +113,7 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr:
pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) {
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
let m = text_transform(m, tr, l.rotation);
let lines_of = LineIndex::new(l);
let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| {
let ps = para(d.para);
if !ps.visible || ps.opacity <= 0.001 {
@@ -144,7 +153,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
draw_glyph(ctx, run, *gid, at, ps.opacity);
continue;
};
let li = line_at(l, run.para, *y);
let li = lines_of.line_at(run.para, *y);
if !grads.iter().any(|(i, _)| *i == li) {
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
@@ -191,8 +200,78 @@ pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) {
}
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
/// Finds the line of a paragraph holding a glyph on baseline `y` without scanning the paragraph's
/// lines for every glyph. A paragraph whose lines are ordered top to bottom (every layout the text
/// crate produces) is searched by bisection; any other falls back to a scan with the same answer.
pub(crate) struct LineIndex<'a> {
l: &'a deckcraft_text::TextLayout,
/// Per paragraph: its lines' tops, bottoms and baselines all rise with the line index.
ordered: Vec<bool>,
}
impl<'a> LineIndex<'a> {
pub(crate) fn new(l: &'a deckcraft_text::TextLayout) -> Self {
let ordered = l
.para_lines
.iter()
.map(|&(first, count)| {
let end = first.saturating_add(count).min(l.lines.len());
l.lines.get(first..end).is_some_and(|ls| {
ls.iter().all(|a| a.top <= a.bottom)
&& ls.windows(2).all(|w| w[0].top <= w[1].top && w[0].bottom <= w[1].bottom && w[0].baseline <= w[1].baseline)
})
})
.collect();
LineIndex { l, ordered }
}
/// The nearest line baseline to `y` among `lines` (the first of equals), as an index into `lines`.
fn nearest(lines: &[deckcraft_text::LineInfo], y: f64) -> Option<usize> {
let i = lines.partition_point(|li| li.baseline < y);
let best = match (i.checked_sub(1), lines.get(i)) {
(Some(p), Some(n)) => {
if (lines[p].baseline - y).abs() <= (n.baseline - y).abs() {
p
} else {
i
}
}
(Some(p), None) => p,
(None, Some(_)) => i,
(None, None) => return None,
};
let b = lines[best].baseline;
Some(lines.partition_point(|li| li.baseline < b))
}
/// The paragraph's lines when they are ordered, with the index of the first.
fn ordered_lines(&self, para: usize) -> Option<(usize, &[deckcraft_text::LineInfo])> {
let &(first, count) = self.l.para_lines.get(para)?;
let end = first.saturating_add(count).min(self.l.lines.len());
(self.ordered[para] && first < end).then(|| (first, &self.l.lines[first..end]))
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
pub(crate) fn line_at(&self, para: usize, y: f64) -> usize {
if let Some((first, ls)) = self.ordered_lines(para) {
return Self::nearest(ls, y).map_or(0, |i| first + i);
}
line_at_scan(self.l, para, y)
}
/// The first line of paragraph `para` whose box holds `y`, else the nearest baseline's line.
pub(crate) fn line_of(&self, para: usize, y: f64) -> Option<usize> {
let Some((first, ls)) = self.ordered_lines(para) else { return crate::warp::line_of_scan(self.l, para, y) };
let i = ls.partition_point(|li| li.bottom + 0.01 < y);
if ls.get(i).is_some_and(|li| y >= li.top - 0.01) {
return Some(first + i);
}
Self::nearest(ls, y).map(|i| first + i)
}
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline), by scanning.
fn line_at_scan(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
if let Some(&(first, count)) = l.para_lines.get(para) {
if count == 0 {
return 0;
@@ -318,3 +397,38 @@ fn draw_glyph_with(
ctx.stroke_path(&path);
}
}
#[cfg(test)]
mod line_index_tests {
use super::*;
use deckcraft_text::{LineInfo, TextLayout};
fn layout(ordered: bool) -> TextLayout {
let line = |para, top: f64| LineInfo { para, top, baseline: top + 8.0, bottom: top + 10.0, ..Default::default() };
let mut lines: Vec<LineInfo> = (0..40).map(|i| line(usize::from(i >= 25), f64::from(i) * 10.0)).collect();
if !ordered {
lines.swap(3, 7);
}
TextLayout { lines, para_lines: vec![(0, 25), (25, 15)], ..Default::default() }
}
#[test]
fn bisection_matches_the_scan() {
for ordered in [true, false] {
let l = layout(ordered);
let idx = LineIndex::new(l_ref(&l));
assert_eq!(idx.ordered, vec![ordered, true]);
for para in 0..2 {
for k in -20..450 {
let y = f64::from(k) * 0.9;
assert_eq!(idx.line_at(para, y), line_at_scan(&l, para, y), "line_at {para} {y}");
assert_eq!(idx.line_of(para, y), crate::warp::line_of_scan(&l, para, y), "line_of {para} {y}");
}
}
}
}
fn l_ref(l: &TextLayout) -> &TextLayout {
l
}
}
+6 -4
View File
@@ -9,13 +9,14 @@ use deckcraft_text::{GlyphRun, TextLayout};
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _};
use vello_cpu::{RenderContext, peniko};
use crate::text_draw::LineIndex;
use crate::{ParaState, color, glyph, scale_of, text_transform};
/// Most points one warped glyph may have (a hostile size or zoom can't explode the work).
const MAX_POINTS: usize = 40_000;
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to.
fn line_of(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to, by scanning.
pub(crate) fn line_of_scan(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
if let Some(&(first, count)) = l.para_lines.get(para) {
if count == 0 {
return None;
@@ -110,6 +111,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
let m = text_transform(m, tr, l.rotation);
let n = l.lines.len();
let lines_of = LineIndex::new(l);
let mut ink: Vec<Option<Rect>> = vec![None; n];
let mut off: Vec<Option<f64>> = vec![None; n];
let mut items: Vec<Item> = Vec::new();
@@ -117,7 +119,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
for (gid, x, y) in &run.glyphs {
let Some(li) = line_of(l, run.para, *y) else { continue };
let Some(li) = lines_of.line_of(run.para, *y) else { continue };
if let Some(o) = off.get_mut(li) {
*o = Some(o.map_or(ref_offset(run), |v| v.max(ref_offset(run))));
}
@@ -156,7 +158,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
return;
}
let y = (d.rect.y0 + d.rect.y1) / 2.0;
let Some(li) = line_of(l, d.para, y) else { return };
let Some(li) = lines_of.line_of(d.para, y) else { return };
ctx.set_transform(m * Affine::translate(ps.offset));
ctx.set_paint(color(d.color, ps.opacity));
for r in d.pieces() {
+29 -12
View File
@@ -260,12 +260,15 @@ pub(crate) struct Tab {
align: TabAlign,
}
/// The paragraph's tab stops in position order (a stop at no finite position is left out), so
/// [`tab_advance`] finds the next one by binary search instead of a scan per tab.
pub(crate) fn tabs_of(props: &ParaProps) -> Vec<Tab> {
props
let mut tabs: Vec<Tab> = props
.tabs
.as_ref()
.map(|t| {
t.iter()
.filter(|s| s.pos.is_finite())
.map(|s| Tab {
pos: s.pos,
align: match s.align.as_str() {
@@ -277,7 +280,9 @@ pub(crate) fn tabs_of(props: &ParaProps) -> Vec<Tab> {
})
.collect()
})
.unwrap_or_default()
.unwrap_or_default();
tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos));
tabs
}
/// Width of the text after the tab at cell `i` up to the next tab or line break, and the width of
@@ -301,18 +306,30 @@ pub(crate) fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) {
/// (`seg`: its width, and its width before the decimal point) as the stop aligns it but never
/// moving back, as PowerPoint does; past the last stop, to the next default stop.
pub(crate) fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 {
for s in stops {
if s.pos > x + 0.5 {
let to = s.pos - x;
return match s.align {
TabAlign::Left => to,
TabAlign::Right => (to - seg.0).max(0.0),
TabAlign::Center => (to - seg.0 / 2.0).max(0.0),
TabAlign::Decimal => (to - seg.1).max(0.0),
};
}
// `stops` is in position order (`tabs_of`): the next stop is the first past x.
// A NaN `x` (a NaN spacing upstream) skips the lookup and takes the finite default-stop path.
if let Some(s) = stops.get(stops.partition_point(|s| s.pos <= x + 0.5)).filter(|_| !x.is_nan()) {
let to = s.pos - x;
return match s.align {
TabAlign::Left => to,
TabAlign::Right => (to - seg.0).max(0.0),
TabAlign::Center => (to - seg.0 / 2.0).max(0.0),
TabAlign::Decimal => (to - seg.1).max(0.0),
};
}
let d = if default_tab > 1.0 { default_tab } else { 72.0 };
let next = ((x / d).floor() + 1.0) * d;
(next - x).max(1.0)
}
#[cfg(test)]
mod tab_tests {
use super::*;
#[test]
fn a_nan_position_takes_the_finite_default_stop() {
let stops = [Tab { pos: 100.0, align: TabAlign::Left }];
assert!(tab_advance(f64::NAN, &stops, 72.0, (0.0, 0.0)).is_finite());
assert_eq!(tab_advance(10.0, &stops, 72.0, (0.0, 0.0)), 90.0);
}
}
+21 -9
View File
@@ -93,12 +93,24 @@ impl BoxGeom {
} else {
text_rect
};
let inner = Rect::new(
r.x0 + bp.inset_l.unwrap_or(7.2),
r.y0 + bp.inset_t.unwrap_or(3.6),
(r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)),
(r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)),
);
// Insets wider than the shape scale down proportionally to fit inside it (#1632).
let (mut ins_l, mut ins_r) = (bp.inset_l.unwrap_or(7.2), bp.inset_r.unwrap_or(7.2));
let (mut ins_t, mut ins_b) = (bp.inset_t.unwrap_or(3.6), bp.inset_b.unwrap_or(3.6));
let w = r.width().max(0.0);
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.
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));
@@ -406,7 +418,7 @@ fn justify_line(p: &ParaShaped, rl: &RawLine, adv: &mut [f64], align: Align, sla
filled = true;
}
}
if stretch && !filled && matches!(align, Align::Justify | Align::JustLow) {
if stretch && !filled && matches!(align, Align::Justify | Align::JustLow | Align::ThaiDistributed) {
let spaces = line_cells.iter().filter(|c| c.ch == ' ').count();
if spaces > 0 {
for (a, c) in adv.iter_mut().zip(line_cells) {
@@ -438,8 +450,8 @@ fn line_left(align: Align, filled: bool, rtl: bool, bx: (f64, f64), line_w: f64,
Align::Center => box_l + (box_r - box_l - line_w) / 2.0,
Align::Distributed if n <= 1 => box_l + slack / 2.0,
// Justified lines that aren't stretched (the last one) sit at the start edge.
Align::Justify | Align::JustLow | Align::Distributed if rtl => box_r - line_w,
Align::Justify | Align::JustLow | Align::Distributed => box_l,
Align::Justify | Align::JustLow | Align::Distributed | Align::ThaiDistributed if rtl => box_r - line_w,
Align::Justify | Align::JustLow | Align::Distributed | Align::ThaiDistributed => box_l,
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ pub struct GlyphRun {
pub link: bool,
/// Opacity of the fill on top of `color`'s own alpha (0 for no fill).
pub alpha: f64,
pub gradient: Option<deckcraft_model::style::Gradient>,
pub gradient: Option<std::sync::Arc<deckcraft_model::style::Gradient>>,
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
pub effects: Option<RunEffects>,
/// East Asian vertical text (`eaVert`): each glyph stands upright, turned a quarter turn
+2 -2
View File
@@ -76,7 +76,7 @@ pub(crate) fn shape_para(
if let Some(c) = &props.bullet_color {
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.strike = None;
rp.highlight = None;
@@ -105,7 +105,7 @@ pub(crate) fn shape_para(
if let Some(c) = &props.bullet_color {
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;
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)));
+2 -2
View File
@@ -46,7 +46,7 @@ pub(crate) struct Style {
pub(crate) highlight: Option<Rgba>,
pub(crate) outline: Option<(Rgba, f64)>,
pub(crate) link: bool,
pub(crate) gradient: Option<deckcraft_model::style::Gradient>,
pub(crate) gradient: Option<std::sync::Arc<deckcraft_model::style::Gradient>>,
pub(crate) effects: Option<RunEffects>,
/// "Regular", "Bold"… for the slot and fallback faces.
pub(crate) style_name: &'static str,
@@ -136,7 +136,7 @@ pub(crate) fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgb
for s in &mut g.stops {
s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None));
}
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g))
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(std::sync::Arc::new(g)))
}
Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None),
_ => (resolve::text_color(ctx, r), 1.0, None),
+46
View File
@@ -1416,6 +1416,16 @@ fn tab_stops_align_the_text_after_them() {
assert!((l.lines[0].edges[4].0 - x0(&l) - 72.0).abs() < 0.01);
}
#[test]
fn many_tabs_over_many_stops_lay_out_by_position() {
// 20,000 stops given out of order, and 2,000 tabs: each tab finds the next stop by position.
let mut stops: Vec<(f64, &str)> = (1..=20_000).rev().map(|k| (k as f64 * 0.05 + 30.0, "l")).collect();
stops.push((f64::NAN, "l"));
let text = "a\t".repeat(2_000);
let l = tabbed(&text, &stops, 1.0e6);
assert!((l.lines[0].edges[2].0 - l.inner.x0 - 30.05).abs() < 0.01, "{:?}", &l.lines[0].edges[..3]);
}
#[test]
fn a_tab_past_the_end_moves_to_the_next_line() {
// PowerPoint: "abcdefghij\tk\tl\tm…" in a narrow box puts the tab that doesn't fit on the next
@@ -1946,3 +1956,39 @@ fn a_paragraph_with_its_own_start_at_restarts_the_count_there() {
// A later paragraph with a new start begins a fresh run.
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
//! 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
//! [`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
@@ -24,7 +24,7 @@ mod print;
pub use files::split_path;
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};
use print::{export, print};
pub use print::{print_page_size, print_params, print_slides};
@@ -47,11 +47,11 @@ pub enum Page {
SaveAs,
Print,
Export,
Account,
About,
}
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.
pub fn id(self) -> &'static str {
@@ -63,12 +63,16 @@ impl Page {
Page::SaveAs => "saveAs",
Page::Print => "print",
Page::Export => "export",
Page::Account => "account",
Page::About => "about",
}
}
pub fn parse(id: &str) -> Option<Page> {
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))
}
@@ -82,7 +86,7 @@ impl Page {
Page::SaveAs => "Save As",
Page::Print => "Print",
Page::Export => "Export",
Page::Account => "Account",
Page::About => "About",
}
}
@@ -95,7 +99,7 @@ impl Page {
Page::SaveAs => Icon::SaveAs,
Page::Print => Icon::Print,
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 {
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.
@@ -412,7 +416,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
Page::SaveAs => save_as(app, ui, &mut bs, &mut out),
Page::Print => print(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
/// there is nothing to sign in to.
pub(super) fn account(app: &mut SlideApp, ui: &mut Ui) {
/// Product information, project links, credits and appearance preferences.
pub(super) fn about(app: &mut SlideApp, ui: &mut Ui) {
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.set_width(340.0);
heading(ui, "Appearance");
@@ -210,33 +229,6 @@ pub(super) fn account(app: &mut SlideApp, ui: &mut Ui) {
})
.response
.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);
});
});
}

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