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
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
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
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
Harrison Booth d453bee54f D604: readable animation effect labels 2026-10-30 18:57:51 -05:00
26 changed files with 90 additions and 304 deletions
-1
View File
@@ -45,4 +45,3 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
Generated
+28 -28
View File
@@ -984,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"arboard",
"chrono",
@@ -1006,7 +1006,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -1015,7 +1015,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1024,14 +1024,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-cli"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -1041,14 +1041,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"chrono",
"deckcraft-anim",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -1086,7 +1086,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-model",
"serde",
@@ -1097,7 +1097,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"kurbo",
"serde",
@@ -1105,7 +1105,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1123,21 +1123,21 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-matroska"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-mcp"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1147,7 +1147,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1165,7 +1165,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"image",
"kurbo",
@@ -1174,7 +1174,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1186,15 +1186,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.15.1"
version = "0.15.0"
[[package]]
name = "deckcraft-opus"
version = "0.15.1"
version = "0.15.0"
[[package]]
name = "deckcraft-pdf"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1212,7 +1212,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1228,7 +1228,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1247,7 +1247,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1261,7 +1261,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -1286,7 +1286,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1295,7 +1295,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -6177,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.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.15.1"
version = "0.15.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
-4
View File
@@ -2,8 +2,6 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-04 · **Change:** trivial (Ogg Opus recovery includes trailing packets without granules in its reported duration; 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)
@@ -152,7 +150,6 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered |
| 2026-11-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 |
@@ -333,7 +330,6 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | trivial | Ogg Opus recovery duration includes its recovered tail; parity scores unchanged |
| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged |
| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged |
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
+1 -35
View File
@@ -219,7 +219,7 @@ fn neighbour(s: &mut Session, p: &Value, forward: bool) -> Result<Value> {
let cmd = if forward { "comment.next" } else { "comment.prev" };
let (threads, slide, index) = {
let st = s.doc()?;
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), super::opt_usize_param(p, "index", cmd)?)
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index"))
};
if threads.is_empty() {
return Err(bad(cmd, "no comments"));
@@ -862,40 +862,6 @@ mod tests {
assert_eq!(here["text"], "on first");
}
#[test]
fn malformed_comment_navigation_index_does_not_change_selection() {
let mut s = Session::with_new();
s.execute("comment.add", &json!({"text": "first"})).unwrap();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
s.execute("comment.add", &json!({"text": "second"})).unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
let shape = s.doc().unwrap().doc.slides.first().and_then(|slide| slide.shapes.first()).map(|shape| shape.id).unwrap();
s.doc_mut().unwrap().selection.shapes = vec![shape];
for (command, value) in [
("comment.next", json!(-1)),
("comment.next", json!(1.5)),
("comment.next", json!("bad")),
("comment.prev", json!(-1)),
("comment.prev", json!(1.5)),
("comment.prev", json!("bad")),
] {
let before = s.doc().unwrap().selection.clone();
let error = s.execute(command, &json!({"index": value})).unwrap_err();
assert!(error.to_string().contains("valid index"), "{command} {value}: {error}");
assert_eq!(s.doc().unwrap().selection, before, "{command} {value}");
}
let zero = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap();
assert_eq!(zero["slide"], 1);
assert_eq!(zero["index"], 0);
s.execute("slide.go", &json!({"index": 0})).unwrap();
let omitted = s.execute("comment.next", &json!({})).unwrap();
assert_eq!(omitted["slide"], 0);
assert_eq!(omitted["index"], 0);
}
#[test]
fn timestamps_are_iso_utc() {
assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
+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 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool (false keeps linear and radial gradients upright on the slide; rectangular and path gradients turn with the shape when drawn)} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
"{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
),
+1 -2
View File
@@ -74,8 +74,7 @@ pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
/// rotateWithShape?}`. The renderer keeps only linear and radial gradients upright when
/// `rotateWithShape` is false; rectangular and path gradients turn with the shape.
/// rotateWithShape?}`.
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
let stops: Vec<GradientStop> = v
.get("stops")?
-3
View File
@@ -4321,9 +4321,6 @@ fn shape_fill_documents_each_gradient_key_and_kind() {
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"]);
// The renderer keeps only linear and radial gradients upright (`paint::fill_path`).
let rotate = cmd::parse_params(about).unwrap().into_iter().find(|p| p.name == "rotateWithShape").unwrap().about;
assert!(rotate.contains("rectangular and path gradients turn with the shape"), "{rotate}");
for kind in ["linear", "radial", "rectangular", "path"] {
assert!(about.contains(kind), "{kind}: {about}");
let g = cmd::shape::gradient_param(&json!({
+3 -7
View File
@@ -1260,8 +1260,9 @@ impl FontDb {
fn instance(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
let open = style.find('{')?;
let base = self.find(family, style[..open].trim())?;
let Some(font) = base.skrifa().filter(|f| !f.axes().is_empty()) else { return Some(base) };
let axis_tags: Vec<[u8; 4]> = font.axes().iter().map(|a| a.tag().to_be_bytes()).collect();
if base.skrifa().is_none_or(|f| f.axes().is_empty()) {
return Some(base);
}
let mut coords = base.coords.clone();
for kv in style[open + 1..].trim_end_matches('}').split(',') {
let (k, v) = kv.split_once(':')?;
@@ -1271,11 +1272,6 @@ impl FontDb {
return None;
}
let tag = [k[0], k[1], k[2], k[3]];
// A tag the font has no axis for changes nothing; keeping it would grow the list with
// every distinct tag in the style text.
if !axis_tags.contains(&tag) {
continue;
}
match coords.iter_mut().find(|(t, _)| *t == tag) {
Some(c) => c.1 = v,
None => coords.push((tag, v)),
-5
View File
@@ -247,11 +247,6 @@ pub fn repair(p: &mut Presentation) {
if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) {
p.notes_size = Presentation::default().notes_size;
}
// A notes page is bounded like a slide: canvas and handout drawing scale with it.
p.notes_size = deckcraft_model::Size::new(
p.notes_size.width.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()),
p.notes_size.height.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()),
);
sane_numbers(p);
}
+1 -8
View File
@@ -299,8 +299,7 @@ impl Outline<'_> {
/// A one-character control symbol (`\\`, `\{`, `\~`, ...).
fn symbol(&mut self, n: u8) {
let c = self.rtf.get(self.i..).and_then(|r| r.chars().next());
self.i = advance(self.rtf, self.i, 1);
self.i += 1;
if self.st.skip {
return;
}
@@ -309,7 +308,6 @@ impl Outline<'_> {
b'~' => self.line.push('\u{a0}'),
b'_' => self.line.push('\u{2011}'),
b'\n' | b'\r' => self.flush(),
_ if !n.is_ascii() => self.line.extend(c),
_ => {}
}
}
@@ -421,11 +419,6 @@ mod tests {
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
}
#[test]
fn multibyte_control_symbol_keeps_character_and_following_text() {
assert_eq!(rtf_to_outline("{\\rtf1 A\\éB\\par}"), "AéB\n");
}
/// `\\_` is RTF's non-breaking hyphen: a visible character, not markup.
#[test]
fn non_breaking_hyphen_is_kept() {
-7
View File
@@ -451,13 +451,6 @@ fn degenerate_slide_sizes_are_repaired() {
}
}
#[test]
fn repair_bounds_the_notes_page_size() {
let mut p = Presentation { notes_size: deckcraft_model::Size::new(1e12, 0.01), ..Default::default() };
repair(&mut p);
assert_eq!((p.notes_size.width, p.notes_size.height), (4032.0, 72.0));
}
/// A file whose slide size is null, a string or missing a side (written by hand or by
/// another tool; NaN is also saved as null) opens with the default size rather than failing
/// as damaged.
-6
View File
@@ -415,11 +415,6 @@ impl CaptureScanner {
}
fn read_parts(src: &(impl ByteSource + ?Sized), parts: &[(u64, u32)]) -> Result<Vec<u8>> {
// Ranges come from a scan of some source; check them against this one before sizing a buffer.
let len = src.len();
if parts.iter().any(|&(o, n)| o.checked_add(u64::from(n)).is_none_or(|end| end > len)) {
return Err(Error::Invalid("packet range lies outside the source".into()));
}
let mut v = Vec::with_capacity(parts.iter().map(|p| p.1 as usize).sum());
for &(o, n) in parts {
let a = v.len();
@@ -513,7 +508,6 @@ impl OpusTiming {
c = timing_add(c, i64::from(durations[k]))?;
ends[k] = Some(c);
}
cursor = Some(c);
break;
}
let Some(g) = stream.packets[j].granule else { break };
-31
View File
@@ -183,27 +183,6 @@ fn opus_toc_durations() {
assert_eq!(opus_packet_samples([0xF8, 0], 0), None);
}
#[test]
fn opus_timing_includes_packets_after_the_last_known_granule() {
let f = opus_file(6, 312, 0);
let o = open(&f).unwrap();
let mut stream = o.streams[0].clone();
// Missing granules on complete-packet pages are non-conforming. The existing recovery
// fallback still assigns their packet positions; total must include the same samples.
stream.packets.last_mut().unwrap().granule = None;
let timing = OpusTiming::of(&stream, 312).unwrap();
assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]);
assert_eq!(timing.total, 6 * 960 - 312);
// With no usable granule, every complete packet still contributes its TOC duration.
for packet in &mut stream.packets {
packet.granule = None;
}
let timing = OpusTiming::of(&stream, 312).unwrap();
assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]);
assert_eq!(timing.total, 6 * 960 - 312);
}
#[test]
fn opus_timing_rejects_a_granule_that_overflows() {
let packet = |granule: i64| Stream {
@@ -295,13 +274,3 @@ fn a_budget_too_small_to_search_is_spent() {
let f = opus_file(6, 312, 0);
assert_eq!(scanner.find(&f, 0, f.len() as u64).unwrap(), None);
}
#[test]
fn read_packet_checks_its_ranges_against_the_source_before_allocating() {
let f = opus_file(10, 312, 500);
let o = open(&f).unwrap();
let s = o.stream_of(Codec::Opus).unwrap();
// The last packet lies past the end of a source cut short: an error, not a read of missing bytes.
let short = &f[..f.len() / 2];
assert!(matches!(o.read_packet(&short, s, 9), Err(Error::Invalid(_))));
}
+4 -5
View File
@@ -307,7 +307,7 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape
.body
.paragraphs
.get(run.para)
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
.unwrap_or_default()
.chars()
.skip(run.chars.0)
@@ -522,8 +522,7 @@ impl Exporter<'_> {
let k = b.width() / sw;
// Raster at the requested dpi relative to the box size on paper.
let scale = raster_scale(b.width(), (sw, sh), self.opts.dpi);
// One clock reading for the raster and the text layer, so a date field cannot differ between them.
let ropts = RenderOpts { scale, threads: 4, now: deckcraft_text::datetime::DateTime::now_local(), ..Default::default() };
let ropts = RenderOpts { scale, threads: 4, ..Default::default() };
// Two rasters per slide: the backdrop (background, master and layout graphics) is the same
// on every slide that shares its layout, so the writer stores it once, and the slide's own
// shapes draw over it (#845).
@@ -564,7 +563,7 @@ impl Exporter<'_> {
}
if self.opts.text_layer {
let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k);
for pt in deckcraft_render::place_slide_with(pres, i, &ropts).texts {
for pt in deckcraft_render::place_slide(pres, i).texts {
self.text_layer(s, &pt, view, false)?;
}
let _ = sh;
@@ -582,7 +581,7 @@ impl Exporter<'_> {
.body
.paragraphs
.get(run.para)
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
.unwrap_or_default()
.chars()
.skip(run.chars.0)
+5 -56
View File
@@ -269,6 +269,8 @@ fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
use deckcraft_model::{Shape, ShapeId, Xfrm};
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Dict, Object, Stream};
let text = "Café Ελληνικά Привет";
let face = FontDb::global().face("Ubuntu", "Regular");
@@ -290,17 +292,8 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0)));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
assert_eq!(page_text(&bytes, 0), text);
}
/// The text of page `index`, read the way a PDF reader does: the content stream's text-showing
/// operators decoded through the page font's ToUnicode map.
fn page_text(bytes: &[u8], index: usize) -> String {
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Dict, Object, Stream};
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
let page = &pdf.pages()[index];
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let page = &pdf.pages()[0];
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
assert_eq!(fonts.len(), 1);
@@ -351,51 +344,7 @@ fn page_text(bytes: &[u8], index: usize) -> String {
_ => {}
}
}
extracted
}
fn ubuntu_runs(runs: Vec<deckcraft_model::text::Run>) -> TextBody {
let paragraph = deckcraft_model::text::Paragraph { runs, ..Default::default() };
let mut body = TextBody { paragraphs: vec![paragraph], ..Default::default() };
for run in &mut body.paragraphs[0].runs {
run.props.font = Some("Ubuntu".into());
run.props.size = Some(20.0);
}
body
}
fn deck_with_body(n: usize, body: TextBody) -> Presentation {
use deckcraft_model::{Shape, ShapeId, Xfrm};
let mut p = deck(n);
for slide in &mut p.slides {
Arc::make_mut(slide).shapes =
vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body.clone()), ..Default::default() }];
}
p
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn slide_number_field_is_recovered_from_the_page_text_layer() {
use deckcraft_model::text::{Run, RunKind};
let mut field = Run::new("<#>");
field.kind = RunKind::Field { field: "slidenum".into() };
let p = deck_with_body(3, ubuntu_runs(vec![Run::new("Page "), field]));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
for (i, want) in ["Page 1", "Page 2", "Page 3"].into_iter().enumerate() {
assert_eq!(page_text(&bytes, i), want);
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn line_break_keeps_the_text_layer_aligned_with_the_layout() {
use deckcraft_model::text::{Run, RunKind};
let mut brk = Run::new("");
brk.kind = RunKind::Break;
let p = deck_with_body(1, ubuntu_runs(vec![Run::new("one"), brk, Run::new("two")]));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
assert_eq!(page_text(&bytes, 0), "onetwo");
assert_eq!(extracted, text);
}
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
-1
View File
@@ -1351,7 +1351,6 @@ fn read_show(sp: &El, shows: &HashMap<String, String>) -> ShowSettings {
without_narration: sp.bool("showNarration") == Some(false),
without_animation: sp.bool("showAnimation") == Some(false),
use_timings: sp.bool("useTimings").unwrap_or(true),
show_media_controls: sp.find("showMediaCtrls").and_then(|e| e.bool("val")).unwrap_or(true),
..Default::default()
};
if sp.child("browse").is_some() {
-8
View File
@@ -1206,14 +1206,6 @@ fn pres_props_xml(p: &Presentation) -> Vec<u8> {
w.open0("p:penClr");
w.val("a:srgbClr", s.pen_color.hex());
w.close("p:penClr");
if !s.show_media_controls {
// "Show media controls" is PowerPoint's p14 extension; absent means shown.
w.open0("p:extLst");
w.open("p:ext", A::new().a("uri", "{2FDB2607-1784-4EEB-B798-7EB5836EED8A}"));
w.empty("p14:showMediaCtrls", A::new().a("xmlns:p14", NS_P14).a("val", 0));
w.close("p:ext");
w.close("p:extLst");
}
w.close("p:showPr");
w.close("p:presentationPr");
w.finish()
-28
View File
@@ -2258,34 +2258,6 @@ fn default_durations_match_powerpoint() {
}
}
/// Show media controls is PowerPoint's `p14:showMediaCtrls` extension inside `p:showPr`: off is
/// written there, under its extension uri and with the p14 namespace declared on the element;
/// on (the default) writes nothing.
#[test]
fn hidden_media_controls_survive_save_and_reopen() {
let props = |p: &Presentation| {
let bytes = deckcraft_pptx::export(p).expect("export");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut x = String::new();
std::io::Read::read_to_string(&mut zip.by_name("ppt/presProps.xml").expect("presProps"), &mut x).expect("read");
x
};
let mut p = Presentation::default();
p.show.show_media_controls = false;
let xml = props(&p);
let show = xml.split("<p:showPr").nth(1).and_then(|r| r.split("</p:showPr>").next()).expect("p:showPr");
assert!(
show.contains(
"<p:ext uri=\"{2FDB2607-1784-4EEB-B798-7EB5836EED8A}\"><p14:showMediaCtrls xmlns:p14=\"http://schemas.microsoft.com/office/powerpoint/2010/main\" val=\"0\"/></p:ext>"
),
"{xml}"
);
assert!(!round(&p).show.show_media_controls);
let default = Presentation::default();
assert!(!props(&default).contains("showMediaCtrls"));
assert!(round(&default).show.show_media_controls);
}
#[test]
fn reflection_angles_and_skews_out_of_range_are_written_inside_the_schema() {
let mut p = Presentation::default();
+1 -1
View File
@@ -54,7 +54,7 @@ pub use images::{decode as decode_image, gif_frame};
pub use morph_path::PathMorph;
pub use morph_text::TextMorph;
pub use pattern::{is_fg as pattern_is_fg, presets as pattern_presets, tile as pattern_tile};
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, place_slide_with, text_outline};
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, text_outline};
pub use table::cell_body as table_cell_body;
pub use table::cell_rects as table_cell_rects;
pub use table::row_heights as table_row_heights;
+5 -26
View File
@@ -2,9 +2,9 @@
//! that put real text over a raster (PDF text layer, links) and for text extraction.
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::{Hyperlink, RunKind, TextBody};
use deckcraft_model::text::{Hyperlink, TextBody};
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use deckcraft_text::{Fields, Opts, TextLayout};
use deckcraft_text::{Opts, TextLayout};
use kurbo::{Affine, Rect};
use crate::{SlideFields, shape_geometry, table};
@@ -52,16 +52,10 @@ pub struct Placed {
/// Text and links of slide `index` (master and layout graphics first, as rendered).
pub fn place_slide(pres: &Presentation, index: usize) -> Placed {
place_slide_with(pres, index, &crate::RenderOpts::default())
}
/// [`place_slide`] with the render options the slide was drawn with, so date fields take the same
/// date and time as the raster (`opts.now`).
pub fn place_slide_with(pres: &Presentation, index: usize, opts: &crate::RenderOpts) -> Placed {
let mut out = Placed::default();
let Some(slide) = pres.slides.get(index) else { return out };
let Some(rctx) = Ctx::for_slide(pres, slide) else { return out };
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts);
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default());
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master {
let mctx = Ctx::for_master(pres, rctx.master);
@@ -160,24 +154,9 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId, s: &Shape, body: &TextBody, tr: Rect, m: Affine) {
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
let m = crate::text_transform(m, tr, layout.rotation);
// Field runs carry their saved text (`‹#›`); the layout drew their values, so the body that
// goes with it (character offsets, text layer) must hold the same values.
let mut placed = body.clone();
for (pi, para) in body.paragraphs.iter().enumerate() {
for (ri, run) in para.runs.iter().enumerate() {
let RunKind::Field { field } = &run.kind else { continue };
let lang = resolve::run(rctx, s, para, &run.props).lang;
if let Some(v) = fields.field_in(field, lang.as_deref())
&& let Some(out_run) = placed.paragraphs.get_mut(pi).and_then(|p| p.runs.get_mut(ri))
{
out_run.text = v;
out_run.kind = RunKind::Text;
}
}
}
// Text hyperlinks: one box per line segment of a linked run.
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
let Some(link) = run_link(&placed, run.para, run.chars.0) else { continue };
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
// The run's visual extent (right-to-left runs run from right to left).
let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
@@ -185,7 +164,7 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId
let r = Rect::new(x0, line.top, x1, line.bottom);
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true });
}
out.texts.push(PlacedText { layout, transform: m, body: placed });
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
}
/// The hyperlink of the model run containing character `at` of paragraph `para`.
+1 -22
View File
@@ -1,7 +1,7 @@
use super::{
Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph,
crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide,
place_slide_with, render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
};
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
use deckcraft_model::style::ColorRef;
@@ -2971,27 +2971,6 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
}
#[test]
fn placed_text_holds_the_field_value_not_the_saved_placeholder() {
let mut p = deck_with(vec![field_shape(Some("slidenum"), "<#>")]);
let second = p.slides[0].clone();
p.slides.push(second);
let placed = place_slide(&p, 1);
assert_eq!(placed.texts.len(), 1);
assert_eq!(placed.texts[0].body.text(), "2");
// The layout was drawn from the same value, so the offsets agree with the body.
assert_eq!(placed.texts[0].layout.runs.iter().map(|r| r.chars.1).max(), Some(1));
}
#[test]
fn placed_text_takes_its_date_from_the_given_render_options() {
use deckcraft_text::datetime::DateTime;
let p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]);
let at = |now: DateTime| place_slide_with(&p, 0, &RenderOpts { now: Some(now), ..Default::default() });
assert_eq!(at(DateTime::new(2026, 10, 9, 20, 5, 7)).texts[0].body.text(), "10/9/2026");
assert_eq!(at(DateTime::new(2027, 3, 4, 0, 0, 0)).texts[0].body.text(), "3/4/2027");
}
/// A chart that has no text colour of its own takes PowerPoint's default, the theme's text colour
/// at 65 % plus 35 %: light on Nocturne's dark slides (it used to be a fixed grey, near-invisible
/// there), 595959 grey on Harbor's light ones.
+36 -4
View File
@@ -8,7 +8,7 @@ use std::sync::Arc;
use deckcraft_model::edit;
use deckcraft_model::text::{Bullet, Paragraph, RunProps, Strike, TextBody};
use egui::text::{CCursor, Galley, LayoutJob, TextFormat};
use egui::text::{Galley, LayoutJob, TextFormat};
use egui::{Align2, Color32, Stroke, Ui, vec2};
use crate::theme;
@@ -210,16 +210,30 @@ pub fn editor(
out
}
/// The (min y, max y) of the row holding char index `at`; `walk` (row index, chars through that
/// row) carries over between calls, so successive non-decreasing `at`s cost one pass in all.
fn row_span_at(galley: &Galley, walk: &mut (usize, usize), at: usize) -> (f32, f32) {
while walk.1 <= at && walk.0 + 1 < galley.rows.len() {
walk.0 += 1;
walk.1 += galley.rows[walk.0].char_count_including_newline().0;
}
galley.rows.get(walk.0).map_or((0.0, 0.0), |r| (r.min_y(), r.max_y()))
}
/// Bullets and numbers at the start of each paragraph's first line of `galley` (laid out from
/// `body`), drawn at `origin`.
fn paint_bullets(painter: &egui::Painter, body: &TextBody, galley: &Galley, origin: egui::Pos2, size: f32, color: Color32) {
let k = size / 12.0;
let mut at = 0usize;
// Paragraph starts only grow, so one forward walk over the rows finds each start's row
// (`pos_from_cursor` would rescan from the top for every bullet).
let mut walk = (0usize, galley.rows.first().map_or(0, |r| r.char_count_including_newline().0));
for (p, bullet) in body.paragraphs.iter().zip(bullets(body)) {
if let Some(b) = bullet {
let row = galley.pos_from_cursor(CCursor::new(at));
let pos = origin + vec2(px(indents(p).0, k), row.center().y);
let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8);
let (min_y, max_y) = row_span_at(galley, &mut walk, at);
let (center_y, height) = ((min_y + max_y) / 2.0, max_y - min_y);
let pos = origin + vec2(px(indents(p).0, k), center_y);
let font = theme::font(height.clamp(size * 0.75, size * 2.0) * 0.8);
painter.text(pos, Align2::LEFT_CENTER, b, font, color);
}
at += p.char_len() + 1;
@@ -241,6 +255,7 @@ mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use deckcraft_model::text::{ParaProps, Run};
use egui::text::CCursor;
use super::*;
@@ -317,6 +332,23 @@ mod tests {
assert!(wrapped.iter().all(|r| (r.pos.x - 22.5).abs() < 1.0), "wrapped lines align with the text");
}
#[test]
fn row_walk_matches_cursor_lookup_at_paragraph_starts() {
let ctx = egui::Context::default();
let body = notes();
let mut laid = None;
let mut out = ctx.run_ui(Default::default(), |ui| laid = Some(galley(ui, &body, 12.0, Color32::BLACK, 300.0)));
out.textures_delta.clear();
let g = laid.unwrap();
let mut walk = (0usize, g.rows.first().map_or(0, |r| r.char_count_including_newline().0));
let mut at = 0usize;
for p in &body.paragraphs {
let want = g.pos_from_cursor(CCursor::new(at));
assert_eq!(row_span_at(&g, &mut walk, at), (want.min.y, want.max.y), "paragraph at char {at}");
at += p.char_len() + 1;
}
}
#[test]
fn typing_keeps_the_formatting_around_it() {
let mut d = Draft::new(&notes());
-6
View File
@@ -1173,12 +1173,6 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
h.run_steps(2);
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide);
}
// #1864: the web popup's Previous/Next buttons send the DOM key names.
for (name, slide) in [("ArrowRight", 1), ("ArrowLeft", 0)] {
bridge.borrow_mut().commands.push(Command::Key { name: name.into(), command: false, shift: false, alt: false, ctrl: false });
h.run_steps(2);
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide, "{name}");
}
bridge.borrow_mut().commands.push(Command::End);
h.run_steps(2);
assert!(h.state().show.is_none());
+1 -1
View File
@@ -94,7 +94,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
show.zoom_arm = false;
}
crate::presenter::Command::Key { name, command, shift, alt, ctrl } => {
if let Some(key) = egui::Key::from_name(&name) {
if let Some(key) = egui::Key::ALL.iter().copied().find(|key| key.name() == name) {
let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl };
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0;
}
+1 -7
View File
@@ -18,13 +18,7 @@ fn walk(dir: &Path, c: &mut Counts) {
let p = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
// `file_type` does not follow links, so a directory link is not walked (and aliases are
// not counted twice).
let Ok(kind) = e.file_type() else { continue };
if kind.is_symlink() {
continue;
}
if kind.is_dir() {
if p.is_dir() {
// Nested fuzz workspaces and build output are not part of the crate.
if name != "target" && name != "fuzz" && !name.starts_with('.') {
walk(&p, c);