Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 22db9261f9 Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:29:08 -04:00
Brandon ThomasandClaude Opus 5.5 19724866a6 Tests: fix two flakes (recovery lock held by a forked mkfifo; notes check not in yet)
recovery::another_running_instance_keeps_its_entries unlocks the other
instance's lock instead of only dropping it: a mkfifo child spawned by a
parallel test shares the open file until it execs, keeping the flock held
(seen on the FreeBSD VM).

spelling_tests::notes_menu_language_dialog_and_repeated_words waits for
the background notes check before right-clicking (seen on ubuntu CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:29:08 -04:00
NetterSilverandClaude Haiku 5.5 83f21e7cb9 Fix add overflow panic in table.columnWidth for a huge column index (#922)
Found while testing crates/engine/src/cmd/table.rs:540 (column_width)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 18:36:12 -06:00
NetterSilverandClaude Haiku 5.5 bdcbee8d0b Make edit.cut a no-op when only a text caret is set (#920)
Found while testing crates/engine/src/cmd/edit.rs:cut (about line 337) and delete (about line 523)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 18:35:36 -06:00
Marco Arnulfo 23144e36fe Contributors: add my name for the About window (#857) 2026-10-17 18:35:13 -06:00
skylar taylor-barrick a178b9955b Merge pull request #849 from MMexicann/fix/pptx-trigger-click-steps
PPTX: preserve separate click steps in trigger sequences
2026-10-17 14:18:58 -06:00
skylar taylor-barrick 2cf4535c5d Merge pull request #939 from skylartaylor/control-input
Close the control port on the first line that isn't a request
2026-10-17 12:14:05 -06:00
Mex 2a4adf3024 Merge branch 'main' into fix/pptx-trigger-click-steps 2026-10-17 13:29:55 +02:00
aiongg 7f1979a1cc Close control connections on the first line that is not a request
A line that is not a JSON object with a string method, including an HTTP
request line or bytes that are not UTF-8, gets one error reply and the
connection closes before anything after it is read.
2026-10-16 16:17:41 +00:00
skylar taylor-barrick 1dc69720db Merge pull request #866 from Silvertree2010/fix/h1968-table-insertrowbelow-insertcolum
Clamp huge index in table.insertRowBelow/insertColumnRight
2026-10-15 23:13:10 -06:00
skylar taylor-barrick 1b64de3499 Merge pull request #816 from albayazid/fix/outline-tables-814
Format: .txt and .md outline includes table cell text (#814)
2026-10-15 22:48:23 -06:00
skylar taylor-barrick ad6be08603 Merge pull request #798 from Silvertree2010/fix/h1435-paste-from-html-text-inside-b-st
Don't paste HTML text as bold when <b> sets font-weight:normal
2026-10-15 22:43:42 -06:00
skylar taylor-barrick e5620b538a Merge pull request #797 from Silvertree2010/fix/h1447-outline-import-deletes-leading-a
Keep leading '-', '*' and '•' text when importing outlines
2026-10-15 22:43:12 -06:00
NetterSilverandClaude Haiku 5.5 1bb408462c Clamp huge index in table.insertRowBelow/insertColumnRight
Found while testing crates/engine/src/cmd/table.rs:291

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 04:09:45 +00:00
skylar taylor-barrick f4731822c9 Merge pull request #854 from dylanmtaylor/ci/add-windows-arm64
ci: add shared Windows ARM64 and nix checks
2026-10-15 21:26:51 -06:00
Dylan M. Taylor f9af68a06f ci: add shared Windows ARM64 and nix checks 2026-10-15 20:48:37 -04:00
skylar taylor-barrick be2f10d8cb Merge pull request #822 from Silvertree2010/fix/h1632-slide-duplicate-appends-the-copy
Keep slide.duplicate copies next to their source in the section
2026-10-15 18:29:50 -06:00
skylar taylor-barrick acea4e3f14 Merge pull request #847 from Silvertree2010/fix/h1767-shape-connect-and-shape-sites-po
Reject shape ids beyond u32 in shape.connect, sites and points
2026-10-15 18:29:04 -06:00
Mex c8dc372519 M6.1: preserve PPTX trigger click steps
Keep later On Click effects and paragraph builds in separate interactive steps.
Add round-trip and headless show regressions.

Contribution on behalf of @MMexicann.
2026-10-15 22:31:32 +02:00
NetterSilverandClaude Haiku 5.5 d7509e7ad6 Reject shape ids beyond u32 in shape.connect, sites and points
Found while testing crates/engine/src/cmd/shape.rs:1154 (shape_id, used by connect/sites); same pattern at crates/engine/src/cmd/points.rs:358-359 and :432-433

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 22:09:59 +02:00
NetterSilverandClaude Haiku 5.5 595d8f17c1 Keep slide.duplicate copies next to their source in the section
Found while testing crates/engine/src/cmd/slide.rs:217-220

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:07:07 +02:00
Sazid Al Bayazid 59d5893793 Format: .txt and .md outline includes table cell text (#814)
slides_to_outline only looked at sh.text, which regular text boxes and
placeholders carry. Tables store their text in cells inside
ShapeKind::Table, so convert to .txt or .md silently skipped every
table cell — with exit 0 and no warning.

The outline now walks into tables: each row becomes a tab-separated
line (multi-paragraph cell text is flattened with spaces). Verified
with the issue's python-pptx repro: the four cell texts appear after
the slide title and text box body.

Tests: a deck with a text box and a 2x2 table — the outline contains
all three text sources.
2026-10-15 18:53:01 +06:00
skylar taylor-barrick 0954289c62 Use the shared PDF writer and report font embedding failures (#708)
Use the pinned shared PDF writer with explicit font embedding errors, preserving app-owned rendering and current main. All seven local gates and fresh Ubuntu/FreeBSD checks passed on the exact integration tree; paired native/WASM PDF bytes, structure, text and pixels match. Original contributor commits are preserved.
2026-10-15 18:51:30 +06:00
NetterSilverandClaude Haiku 5.5 062adfe405 Don't paste HTML text as bold when <b> sets font-weight:normal
Found while testing crates/engine/src/cmd/richclip.rs:187

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:52:15 +00:00
NetterSilverandClaude Haiku 5.5 aa681eafad Keep leading '-', '*' and '•' text when importing outlines
Found while testing crates/format/src/lib.rs:421

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:43:29 +02:00
skylar taylor-barrick 9859da3925 Use the shared PDF writer and report font embedding failures (#708)
Use the pinned shared PDF writer with explicit font embedding errors, preserving app-owned rendering and current main. All seven local gates and fresh Ubuntu/FreeBSD checks passed on the exact integration tree; paired native/WASM PDF bytes, structure, text and pixels match. Original contributor commits are preserved.
2026-10-14 21:20:34 -06:00
skylar taylor-barrick 09c7f60567 PDFW2: integrate current main with shared PDF writing 2026-10-14 21:00:04 -06:00
Brandon Thomas 155c1cfce2 Merge PR #776 from skylartaylor: ci: add comfortable runtime limits to unbounded jobs 2026-10-14 22:42:44 -04:00
Brandon ThomasandClaude Opus 5.5 a15d93acdd Fix up #747 and #772 from review: docs keep their status headers (log rows stay), no milestone renaming; the bullet test no longer saves screenshots; clippy's as_chunks in the bullet render test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:00:48 -04:00
Brandon Thomas 561902d0b3 Merge PR #772 from hajdukste: Text: show gray list markers before typing
# Conflicts:
#	crates/render/src/tests.rs
#	crates/text/src/tests.rs
2026-10-14 21:59:44 -04:00
Brandon Thomas a74191dba4 Merge PR #747 from MMexicann: MCP: validate reply IDs and bounded response frames (#718) 2026-10-14 21:59:44 -04:00
Brandon Thomas 593fd215ff Merge PR #773 from Silvertree2010: Grow table merges to include merged areas they overlap 2026-10-14 21:59:39 -04:00
Brandon Thomas 1cba8f0f60 Merge PR #769 from switch87: Preferences: Interface size applies when the slider is let go 2026-10-14 21:59:39 -04:00
Brandon Thomas 404d303c2e Merge PR #768 from switch87: Render: a cropped picture keeps its resolution (Intersect no longer blurs it) 2026-10-14 21:59:39 -04:00
Brandon Thomas e4fd7a29cc Merge PR #760 from albayazid: Text: anchorCtr centres the drawn text block - right-aligned text stayed glued to the box edge (#736) 2026-10-14 21:59:39 -04:00
Brandon Thomas 0b870df463 Merge PR #759 from midasmatterhorn: text: a bare normAutofit draws at 100% and an edit stores the fitted scale (#724) 2026-10-14 21:59:39 -04:00
Brandon Thomas 816f256c5f Merge PR #758 from midasmatterhorn: control: a request that timed out while queued is abandoned, never run late (#719) 2026-10-14 21:59:38 -04:00
Brandon Thomas 60fa10f99f Merge PR #757 from switch87: Preferences: Cancel, Esc and the close button undo the dialog's changes (#752) 2026-10-14 21:59:38 -04:00
Brandon Thomas 82ef9642a2 Merge PR #749 from midasmatterhorn: media: walk ID3 tag chains iteratively so crafted files cannot overflow the stack 2026-10-14 21:59:38 -04:00
Brandon Thomas 672d86c0e4 Merge PR #746 from IanHollow: Add an editor screenshot to AppStream metadata 2026-10-14 21:59:38 -04:00
Brandon Thomas 54d3f19b5b Merge PR #738 from xmaks82: Fonts: falling back to the default face no longer deadlocks 2026-10-14 21:59:38 -04:00
Brandon Thomas 2c57163549 Merge PR #734 from switch87: Slide: a new slide's body goes into the layout's content placeholder 2026-10-14 21:59:38 -04:00
skylar taylor-barrick 0cbe475f13 fix(pdf): reconcile writer with notes and handout masters (PDFW)
Preserve notes and handout master export, visible text and native parallel
compression while propagating shared font errors through each export path.
Keep the dependency check accurate for native and WASM feature resolution.

PR #708.
2026-10-14 19:44:21 -06:00
NetterSilverandClaude Haiku 5.5 0222a90e5d Grow table merges to include merged areas they overlap
Found while testing crates/model/src/table.rs:224 (Table::merge)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:07:48 +00:00
hajdukste 51de1ad2cf Text (#93): show gray markers on empty list paragraphs 2026-10-14 17:58:53 -07:00
Gert Pellin 8b08718b9a Preferences: Interface size applies when the slider is let go (#767)
Dragging the Interface size slider set the interface scale on every
frame, which grew or shrank the slider under the pointer and ran it to
one of its ends. Keep the value pending in the dialog while the pointer
holds the slider and apply it on release; keyboard changes still apply
at once.
2026-10-15 01:49:41 +02:00
Gert Pellin 7cda7eda1d Merge origin/main into fix/725-body-placeholder
Both sides added tests at the same place in engine tests.rs; keep both.
Document::alloc_id now returns an Option, so the #725 test unwraps it.
2026-10-15 01:39:24 +02:00
Gert Pellin 57c03d36fc Render: decode a cropped picture for the whole image's size, not its box's (#762)
A picture was decoded at the size of its box on screen. A cropped picture
shows only part of the image, magnified, so it was decoded at a fraction of
the resolution it needs and stretched back up: Merge Shapes > Intersect,
which crops the picture to the new smaller box, left it visibly blurred.
Size the decode from the rectangle the whole image occupies instead.
2026-10-15 01:28:10 +02:00
Sazid Al Bayazid 40e05abd45 Text: anchorCtr centres the drawn text block - right-aligned text stayed glued to the box edge (#736)
anchorCtr's centreing offset was computed for a block that starts at
the box's left edge and then added to lines that alignment had already
placed within the box. For a right-aligned line the two shifts
stacked: the line ended up starting at the box's centre and ran past
the right edge - on the tdf165341 fixture the sentence was cut off at
"...paragraph rig". The clamp at zero made it worse for content wider
than the box: all the overflow went to the right only.

The origin is now set so the drawn text block's own centre (min/max of
the laid-out line extents) lands on the box's centre: every alignment
stays inside the box when it fits, and overflow centres symmetrically
past both edges, as PowerPoint draws it. Empty lines carry no ink and
stay out of the block; vertical text and multi-column bodies are
unchanged (centring is unimplemented for them, as before).

Tests: right/left-aligned, trailing empty paragraph, mixed alignment,
symmetric overflow, and a no-anchorCtr control - the block's centre
equals the box's centre in every case.

Reported with a public LibreOffice test fixture (tdf165341.pptx);
verified by rendering the slide before (text cut at the right edge)
and after (whole sentence centred).
2026-10-15 04:41:37 +06:00
Gert Pellin b69613bf88 Preferences: Cancel, Esc and the close button undo the dialog's changes (#752)
The Preferences dialog applies its controls as they change (appearance,
interface size, the editing checkboxes, the comment author), and its Cancel
result was discarded, so Cancel did nothing and the window stayed open.

The dialog now remembers the settings it opened with. Cancel, Esc and the
window's close button put them back and close it; Done keeps the changes.
2026-10-14 23:58:43 +02:00
midasmatterhorn a82f691993 text: a bare normAutofit draws at 100% and an edit stores the fitted scale (#724) 2026-10-15 00:56:41 +03:00
midasmatterhorn 82ee5dec25 media: walk ID3 tag chains iteratively so crafted files cannot overflow the stack 2026-10-15 00:55:32 +03:00
midasmatterhorn 2568c55afa control: a request that timed out while queued is abandoned, never run late (#719) 2026-10-15 00:37:58 +03:00
Mex f4ce764d1c M13.1: validate remote MCP replies (#718)
Reject unrelated, malformed, incomplete and oversized replies without replaying commands.
Reconnect before sending when unsolicited reply bytes are buffered.

Contribution on behalf of @MMexicann.
2026-10-14 22:24:15 +02:00
IanHollow 16bef64ea1 Add an editor screenshot to AppStream metadata
Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com>
2026-10-14 13:13:44 -07:00
Магда М.Г. 8c3404fc87 Fonts: falling back to the default face no longer deadlocks
cascade's last fallback searched the loaded faces and, finding none that
draws the cluster, called `first` while the search's read guard was still
alive (a temporary in the match arm lives to the end of the `let`).
`first` looks up the default family, which loads it through add_fonts,
and add_fonts takes the faces' write lock even when there is nothing to
add: the thread waited on itself, and every other thread then waited on
the loading lock it held.

It shows when a run's chain is empty and nothing loaded draws the
character before the default family has been looked up, so it depends on
which test (or document) gets there first: deckcraft-text's tests hung in
4 of 10 parallel runs here (all threads parked in system_fallback /
try_family, one in add_fonts' write), and in 0 of 15 with this change.

The search now finishes before `first` runs. New test
falling_back_to_the_default_face_does_not_deadlock reaches that path on a
fresh database; it times out on main and passes with the fix.
2026-10-14 20:35:43 +02:00
Gert Pellin 50d9ad00e6 Slide: a new slide's body goes into the layout's content placeholder
slide.new put the body into the first non-title placeholder in shape order,
so a layout with a small body placeholder ahead of its content placeholder
(as Keynote exports "Title & Bullets") got the body in the small box and
left the content placeholder empty.

The body now goes to the main content placeholder: idx 1 first, then a
full-size placeholder over a half/quarter one, then a text placeholder over
a picture/chart/table one, keeping shape order on ties.

Fixes #725
2026-10-14 19:06:35 +02:00
skylar taylor-barrick 42fb6728fa PDFW2: use fixed-size chunks in PDF text regression 2026-10-14 00:11:33 -06:00
skylar taylor-barrick aeaa3684a8 TEXT-TEST: require actual Symbol character coverage 2026-10-13 13:39:33 -06:00
skylar taylor-barrick c536c6e905 PDFW2: adopt shared PDF writer with explicit embedding errors 2026-10-13 13:39:33 -06:00
skylar taylor-barrick 7d5dd85209 UI-FONTS: continue fallback selection past ineligible faces 2026-10-13 13:39:33 -06:00
68 changed files with 2542 additions and 312 deletions
+33
View File
@@ -0,0 +1,33 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
+30
View File
@@ -0,0 +1,30 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall.
name: Windows ARM64
on:
push:
branches: [release]
pull_request:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 3 * * 1"
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: deckcraft
app-dir: DeckCraft
+14
View File
@@ -61,3 +61,17 @@ families excluded by `crates/fonts/build.rs`:
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text |
Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular.
## PDF writer colour profiles
`craft-pdf` is used with only its `write` feature and default features disabled. Krilla
0.8.2 embeds the following profiles; these were already reachable through the direct Krilla
exporter. No interpreter fallback fonts or CMaps are enabled, and no profile or font binary
is copied into this repository. The licence text is in `licenses/pdf-writer/LICENSE-ICC.txt`.
| Asset | Author | Source | Licence | SHA-256 | Used for |
|---|---|---|---|---|---|
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v2-magic.icc | CC0-1.0 | `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sGrey-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v4.icc | CC0-1.0 | `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v2-magic.icc | CC0-1.0 | `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF export colour profiles |
Generated
+113 -56
View File
@@ -605,6 +605,12 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.6.0"
@@ -814,6 +820,15 @@ dependencies = [
"windows 0.54.0",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
dependencies = [
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -877,7 +892,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"arboard",
"cpal",
@@ -896,7 +911,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -905,7 +920,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -914,14 +929,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"proptest",
]
[[package]]
name = "deckcraft-cli"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -930,14 +945,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -962,7 +977,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -974,7 +989,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-model",
"serde",
@@ -985,7 +1000,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"kurbo",
"serde",
@@ -993,7 +1008,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1002,7 +1017,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1011,7 +1026,7 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-bitstream",
"thiserror 2.0.21",
@@ -1019,11 +1034,11 @@ dependencies = [
[[package]]
name = "deckcraft-matroska"
version = "0.7.0"
version = "0.8.0"
[[package]]
name = "deckcraft-mcp"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1033,7 +1048,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1051,7 +1066,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"image",
"kurbo",
@@ -1060,7 +1075,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1072,30 +1087,33 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.7.0"
version = "0.8.0"
[[package]]
name = "deckcraft-opus"
version = "0.7.0"
version = "0.8.0"
[[package]]
name = "deckcraft-pdf"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
"deckcraft-model",
"deckcraft-render",
"deckcraft-text",
"hayro-syntax",
"krilla",
"kurbo",
"log",
"serde",
"thiserror 2.0.21",
"wasm-bindgen-test",
]
[[package]]
name = "deckcraft-pptx"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1111,7 +1129,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1130,7 +1148,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1144,7 +1162,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1166,7 +1184,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1175,7 +1193,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -1586,12 +1604,6 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "fearless_simd"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -2005,7 +2017,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
dependencies = [
"fearless_simd 1.1.0",
"hayro-ccitt",
]
@@ -2014,9 +2025,6 @@ name = "hayro-jpeg2000"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
dependencies = [
"fearless_simd 0.4.1",
]
[[package]]
name = "hayro-syntax"
@@ -2024,26 +2032,13 @@ version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
dependencies = [
"flate2",
"hayro-jbig2",
"hayro-jpeg2000",
"memchr",
"rustc-hash 2.1.3",
"smallvec",
"zune-jpeg",
]
[[package]]
name = "hayro-write"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a"
dependencies = [
"flate2",
"hayro-syntax",
"pdf-writer",
]
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -2430,15 +2425,13 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
dependencies = [
"base64",
"bumpalo",
"flate2",
"float-cmp",
"gif",
"hayro-write",
"image-webp",
"imagesize",
"indexmap",
@@ -2588,6 +2581,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "minicov"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"cc",
"walkdir",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2698,6 +2701,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -3095,6 +3107,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "open"
version = "5.4.4"
@@ -4615,7 +4633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
dependencies = [
"bytemuck",
"fearless_simd 0.4.1",
"fearless_simd",
"guillotiere",
"log",
"peniko",
@@ -4630,7 +4648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd 0.4.1",
"fearless_simd",
"guillotiere",
"log",
"peniko",
@@ -4758,6 +4776,45 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-bindgen-test"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
dependencies = [
"async-trait",
"cast",
"js-sys",
"libm",
"minicov",
"nu-ansi-term",
"num-traits",
"oorandom",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
"wasm-bindgen-test-shared",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "wasm-bindgen-test-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
[[package]]
name = "wayland-backend"
version = "0.3.17"
@@ -5663,7 +5720,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"serde_json",
]
+6 -3
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.7.0"
version = "0.8.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/deckcraft"
[workspace.dependencies]
@@ -57,7 +57,7 @@ harfrust = "0.13"
vello_cpu = "0.2"
vello_common = { version = "0.2", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
krilla = { version = "0.8", features = ["pdf"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
i_overlay = "9"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
@@ -93,3 +93,6 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
+8
View File
@@ -72,3 +72,11 @@ Code dependencies
https://github.com/pdeljanov/Symphonia.
- The H.264, HEVC, VP9, AV1, Opus, MP4, Matroska and Ogg crates under crates/ are ported from
FilmCraft (same owner, MIT OR Apache-2.0).
- Krilla 0.8.2 (https://github.com/LaurenzV/krilla) is used through craft-pdf under
MIT OR Apache-2.0. craft-libs vendors that version with page/surface unwind recovery
and rejection of serialization after failed cleanup. Its upstream licence files are
retained in craft-libs/licenses/pdf-writer. Its bundled Compact ICC Profiles by Clinton Ingram
(https://github.com/saucecontrol/Compact-ICC-Profiles) are CC0-1.0; the profile
licence is retained in licenses/pdf-writer/LICENSE-ICC.txt and exact files, sources
and hashes are in ATTRIBUTION.md.
+3
View File
@@ -97,6 +97,9 @@ Today it can:
## Getting started
PDF export uses the shared `craft-pdf` writer with import features disabled. See
[`crates/pdf/README.md`](crates/pdf/README.md) for the dependency and validation requirements.
```sh
git clone https://github.com/storytold/deckcraft
cd deckcraft
+10
View File
@@ -131,6 +131,13 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions |
| 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
| 2026-10-13 | Interface font fallback continues to eligible installed faces when the first covering face is colour or exceeds the 48 MiB limit; document fallback order and the CJK/IME rendering assertions are retained (#101) |
| 2026-10-13 | PDFW2: adopt the shared writer with native unwind recovery and explicit font-embedding failures; preserve app-owned export behavior and keep reader dependencies out of production |
| 2026-10-13 | Eyedropper: Shape Fill, Shape Outline, Font Color and every other colour menu end with an Eyedropper that takes the colour drawn at a point of the slide (hover shows it, a click applies it, Esc cancels); `slide.colorAt` gives the same from scripts and MCP (#367) |
| 2026-10-13 | Slide show: Honeycomb's hexagons cover the whole slide again — the bottom quarter stayed black until the endpoint (#420) |
| 2026-10-13 | Copy and Cut from the ribbon, the slide's right-click menu and the menus put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431) |
@@ -221,6 +228,9 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
| 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) |
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
| 2026-10-13 | trivial | Progress log: Eyedropper in the colour menus, slide.colorAt (#367) |
| 2026-10-13 | trivial | Progress log: video seek frame-loss race and regression coverage |
+150 -16
View File
@@ -1,8 +1,11 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`deckcraft-cli mcp --connect`) wraps.
//!
//! Every resource a client can hold is bounded ([`Limits`]): simultaneous connections, request
//! line length, queued requests, and how long a socket may sit idle or blocked on a write.
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
//! closed, so nothing sent after it runs. Every resource a client can hold is bounded
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
//! socket may sit idle or blocked on a write.
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
use std::net::{TcpListener, TcpStream};
@@ -120,6 +123,53 @@ fn read_line(r: &mut impl BufRead, max: usize) -> Line {
}
}
/// One request line, parsed.
enum Parsed {
Request {
id: Value,
method: String,
params: Value,
},
Blank,
/// Not a request: reply with this error and close the connection.
Reject(String),
}
fn parse(bytes: &[u8]) -> Parsed {
let Ok(text) = std::str::from_utf8(bytes) else {
return Parsed::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
let text = text.trim();
if text.is_empty() {
return Parsed::Blank;
}
if is_http_request_line(text) {
return Parsed::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
}
let msg = match serde_json::from_str::<Value>(text) {
Ok(v) => v,
Err(e) => return Parsed::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Some(obj) = msg.as_object() else {
return Parsed::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(method) = obj.get("method").and_then(Value::as_str) else {
return Parsed::Reject("not a control request (missing string `method`); closing the connection".into());
};
Parsed::Request {
id: obj.get("id").cloned().unwrap_or(Value::Null),
method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})),
}
}
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
fn is_http_request_line(text: &str) -> bool {
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits) {
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
@@ -136,25 +186,32 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
}
Line::Closed => break,
};
let Ok(line) = String::from_utf8(line) else {
if writeln!(out, "{}", json!({"ok": false, "error": "bad JSON: not UTF-8"})).is_err() {
let reply = match parse(&line) {
Parsed::Blank => continue,
Parsed::Reject(e) => {
// The connection closes either way; a peer that can't read the reason loses nothing.
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
break;
}
continue;
};
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
Parsed::Request { id, method, params } => {
let (req, rrx) = ControlRequest::new(method, params);
let state = Arc::clone(&req.state);
let mut r = match tx.try_send(req) {
Ok(()) => {
ctx.request_repaint();
rrx.recv_timeout(limits.reply_timeout).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}))
match rrx.recv_timeout(limits.reply_timeout) {
Ok(v) => v,
Err(_) => {
// Give up on the request. While it was still queued this makes
// sure it never runs late (#719); if the app had already taken
// it, its outcome is unknown rather than a definite failure.
if ControlRequest::abandon(&state) {
json!({"ok": false, "error": "timeout"})
} else {
json!({"ok": false, "error": "timeout: the app had already started the request, so it may still run; its outcome is unknown"})
}
}
}
}
Err(TrySendError::Full(_)) => json!({"ok": false, "error": "busy: too many queued requests"}),
Err(TrySendError::Disconnected(_)) => break,
@@ -164,7 +221,6 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
if let Err(e) = writeln!(out, "{reply}") {
if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) {
@@ -232,6 +288,45 @@ mod tests {
assert_eq!(v["result"], "ui.inspect");
}
/// Every reply line until the server closes the connection.
fn replies_until_closed(r: &mut BufReader<TcpStream>) -> Vec<Value> {
std::iter::from_fn(|| reply(r)).collect()
}
/// A browser can POST to the port: its request line closes the connection before the JSON
/// line in its body is read, so nothing is dispatched.
#[test]
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
let (addr, rx) = server(Limits { max_line: 512, ..small() });
let (mut r, mut w) = client(addr);
w.write_all(
b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n",
)
.unwrap();
let replies = replies_until_closed(&mut r);
assert_eq!(replies.len(), 1, "{replies:?}");
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
assert!(rx.try_recv().is_err(), "the body was dispatched");
}
#[test]
fn a_line_that_is_not_a_request_closes_the_connection() {
let (addr, rx) = server(small());
ui(rx);
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
let (mut r, mut w) = client(addr);
w.write_all(junk).unwrap();
w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
let replies = replies_until_closed(&mut r);
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
}
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
assert!(is_http_request_line("PRI * HTTP/2.0"));
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
}
#[test]
fn oversized_line_closes_connection() {
let (addr, rx) = server(small());
@@ -296,4 +391,43 @@ mod tests {
assert_eq!(a["error"], "timeout");
drop(rx);
}
/// A request that timed out while it was still queued must not run when the window resumes (#719).
#[test]
fn timed_out_request_does_not_run_when_the_window_resumes() {
let (addr, rx) = server(Limits { max_line: 512, ..small() });
let (mut r, mut w) = client(addr);
writeln!(w, r#"{{"id":1,"method":"engine.execute","params":{{"command":"slide.new"}}}}"#).unwrap();
let t = reply(&mut r).unwrap();
assert_eq!(t["error"], "timeout");
// The window resumes and drains the queue, exactly as `SlideApp::drain_control` does.
let mut app = deckcraft_ui_egui::SlideApp::new(deckcraft_engine::Session::with_new(), deckcraft_ui_egui::Services::default());
let ctx = egui::Context::default();
let before = app.session.active().unwrap().doc.slides.len();
let req = rx.try_recv().expect("the timed-out request is still queued");
if req.take() {
let _ = deckcraft_ui_egui::control::handle(&mut app, &ctx, &req);
}
assert_eq!(app.session.active().unwrap().doc.slides.len(), before, "a request that timed out must not run when the window resumes");
drop(rx);
}
/// A request the app had already started when the reply timed out reports an unknown outcome,
/// not a definite failure (#719).
#[test]
fn a_request_already_started_when_the_reply_times_out_reports_an_unknown_outcome() {
let (addr, rx) = server(small());
let ui = std::thread::spawn(move || {
let req = rx.recv().expect("the request is queued");
assert!(req.take(), "the app claims the request");
// Still running it when the sender's wait expires.
std::thread::sleep(Duration::from_millis(450));
let _ = req.reply.send(json!({"ok": true, "result": "ran"}));
});
let (mut r, mut w) = client(addr);
writeln!(w, r#"{{"id":1,"method":"m"}}"#).unwrap();
let v = reply(&mut r).unwrap();
assert!(v["error"].as_str().unwrap().contains("outcome is unknown"), "{v}");
ui.join().unwrap();
}
}
+5
View File
@@ -54,3 +54,8 @@ real_name = "Nikita Rakitin"
[people.RCShoemaker]
real_name = "Ryan C. Shoemaker"
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
+5 -1
View File
@@ -175,7 +175,11 @@ fn copied_html(s: &Session) -> String {
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
let caret = copying_caret(s);
let t = copy_inner(s, p)?;
let html = if caret { String::new() } else { copied_html(s) };
if caret {
// Nothing is selected, so there is nothing to cut; `delete` would remove the next character.
return Ok(json!({"text": t, "html": ""}));
}
let html = copied_html(s);
delete(s, p)?;
Ok(json!({"text": t, "html": html}))
}
+1 -1
View File
@@ -1089,7 +1089,7 @@ fn columns(s: &mut Session, p: &Value) -> Result<Value> {
}
fn autofit(s: &mut Session, p: &Value) -> Result<Value> {
let m = match str_param(p, "mode").unwrap_or("none") {
"shrink" => AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 },
"shrink" => AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 },
"resize" | "shape" => AutoFit::Shape,
_ => AutoFit::None,
};
+4 -4
View File
@@ -355,8 +355,8 @@ fn refit(sh: &mut Shape) {
/// The one shape whose points are edited: `id`, or the single selected shape.
fn target(s: &Session, p: &Value, cmd: &str) -> Result<ShapeId> {
let st = s.doc()?;
let id = match p.get("id").and_then(Value::as_u64) {
Some(i) => ShapeId(i as u32),
let id = match id_param(p, "id", cmd)? {
Some(i) => i,
None => match st.selection.shapes.as_slice() {
[one] => *one,
_ => return Err(bad(cmd, "select one shape")),
@@ -429,8 +429,8 @@ pub fn slide_vertices(doc: &deckcraft_model::Presentation, sel: &crate::Selectio
fn points(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = match p.get("id").and_then(Value::as_u64) {
Some(i) => ShapeId(i as u32),
let id = match id_param(p, "id", "shape.points")? {
Some(i) => i,
None => *st.selection.shapes.first().ok_or_else(|| bad("shape.points", "select a shape"))?,
};
let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?;
+28 -5
View File
@@ -189,8 +189,8 @@ fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
let mut underline = i32::from(name == "u");
if let Some(style) = attr(src, "style") {
let s = style.to_ascii_lowercase();
if s.contains("font-weight:bold") || s.contains("font-weight: bold") || weight_bold(&s) {
bold = 1;
if let Some(on) = weight_bold(&s) {
bold = i32::from(on);
}
if s.contains("font-style:italic") || s.contains("font-style: italic") {
italic = 1;
@@ -206,10 +206,19 @@ fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
(bold, italic, underline)
}
fn weight_bold(style: &str) -> bool {
let Some(at) = style.find("font-weight") else { return false };
/// Whether a `font-weight` in `style` turns bold on (`Some(true)`) or off (`Some(false)`).
fn weight_bold(style: &str) -> Option<bool> {
let at = style.find("font-weight")?;
let rest = style[at + "font-weight".len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace());
rest.starts_with('6') || rest.starts_with('7') || rest.starts_with('8') || rest.starts_with('9')
if rest.starts_with("bold") {
return Some(true);
}
if rest.starts_with("normal") || rest.starts_with("lighter") {
return Some(false);
}
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
let weight: u32 = digits.parse().ok()?;
Some(weight >= 600)
}
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
@@ -308,6 +317,20 @@ fn fold(s: &str) -> String {
mod tests {
use super::*;
#[test]
fn google_docs_normal_weight_wrapper_is_not_bold() {
let html = "<meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-1\"><span style=\"font-weight:400\">plain words</span></b>";
let body = html_to_body(html).unwrap();
assert_eq!(body.text(), "plain words");
assert_eq!(body.paragraphs[0].runs[0].props.bold, None);
let light = html_to_body("<b style=\"font-weight:300\">thin</b>").unwrap();
assert_eq!(light.paragraphs[0].runs[0].props.bold, None);
let plain = html_to_body("<b>strong</b>").unwrap();
assert_eq!(plain.paragraphs[0].runs[0].props.bold, Some(true));
let heavy = html_to_body("<span style=\"font-weight:700\">heavy</span>").unwrap();
assert_eq!(heavy.paragraphs[0].runs[0].props.bold, Some(true));
}
#[test]
fn bold_italic_and_underline_round_trip() {
let mut body = TextBody::from_text("Hi there");
+5 -7
View File
@@ -1150,19 +1150,17 @@ fn media_options(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({}))
}
fn shape_id(p: &Value, key: &str) -> Option<deckcraft_model::ShapeId> {
p.get(key).and_then(Value::as_u64).map(|v| deckcraft_model::ShapeId(v as u32))
}
fn sites(s: &mut Session, p: &Value) -> Result<Value> {
let id = shape_id(p, "id").ok_or_else(|| bad("shape.sites", "missing `id`"))?;
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
let st = s.doc()?;
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
}
fn connect(s: &mut Session, p: &Value) -> Result<Value> {
let (Some(from), Some(to)) = (shape_id(p, "from"), shape_id(p, "to")) else { return Err(bad("shape.connect", "missing `from` / `to`")) };
let (Some(from), Some(to)) = (id_param(p, "from", "shape.connect")?, id_param(p, "to", "shape.connect")?) else {
return Err(bad("shape.connect", "missing `from` / `to`"));
};
let st = s.doc()?;
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
let (a, b) = (site_list(from), site_list(to));
@@ -1186,7 +1184,7 @@ fn connect(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let (p0, p1) = (a[ia], b[ib]);
let id = match shape_id(p, "id") {
let id = match id_param(p, "id", "shape.connect")? {
Some(id) => id,
None => {
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
+40 -1
View File
@@ -122,7 +122,7 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
sh.text = Some(TextBody::from_text(t));
}
if let Some(b) = str_param(p, "body")
&& let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| !k.is_title()))
&& let Some(sh) = body_placeholder(&mut slide.shapes)
{
let mut body = TextBody::from_text(b);
// Leading tabs set the outline level, as in text.set.
@@ -158,6 +158,25 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// The placeholder a new slide's `body` goes into: the layout's main content placeholder.
/// Prefers `idx="1"` (the content slot PowerPoint fills), then a full-size text placeholder over
/// a `half`/`quarter` one, then a text placeholder over a picture/chart/table one; ties keep
/// shape order, so a layout with a single body placeholder behaves as before.
fn body_placeholder(shapes: &mut [deckcraft_model::Shape]) -> Option<&mut deckcraft_model::Shape> {
use deckcraft_model::PhType;
let rank = |sh: &deckcraft_model::Shape| {
let ph = sh.ph.as_ref()?;
if ph.kind.is_title() {
return None;
}
let text = matches!(ph.kind, PhType::Body | PhType::Obj | PhType::SubTitle);
let small = matches!(ph.size.as_deref(), Some("half" | "quarter"));
Some((ph.idx != 1, small, !text))
};
let best = shapes.iter().enumerate().filter_map(|(i, sh)| rank(sh).map(|r| (r, i))).min()?.1;
shapes.get_mut(best)
}
fn index_of(s: &Session, p: &Value) -> Result<usize> {
let st = s.doc()?;
let i = usize_param(p, "index").unwrap_or(st.selection.slide);
@@ -199,6 +218,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
&& let Some(sec) = doc.sections.get_mut(si)
{
sec.slides.push(nid);
// Keep the section list in document order so the copy sits next to its source.
let order: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
}
sel.slide = (last + new_ids.len()).min(doc.slides.len().saturating_sub(1));
@@ -615,6 +637,23 @@ mod tests {
use crate::Session;
#[test]
fn duplicate_keeps_section_order_in_step_with_slides() {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < 3 {
s.execute("slide.new", &json!({})).expect("new slide");
}
s.execute("section.add", &json!({"at": 0})).expect("section");
let orig = s.doc().expect("doc").doc.slides.first().expect("slide").id;
s.execute("slide.duplicate", &json!({"index": 0})).expect("dup");
let st = s.doc().expect("doc");
let all: Vec<_> = st.doc.slides.iter().map(|x| x.id).collect();
let sec = &st.doc.sections.first().expect("section").slides;
assert_eq!(all.len(), 4);
assert_eq!(sec, &all, "section order must match slide order");
assert_eq!(sec.first(), Some(&orig));
}
#[test]
fn new_slide_body_leading_tabs_become_levels() {
let mut s = Session::with_new();
+2 -2
View File
@@ -541,7 +541,7 @@ fn shadow(blur: f64, dist: f64) -> Shadow {
/// `title` false every line is a bullet (the bullet boxes of the bullet lists).
fn text_shape(preset: &str, x: Xfrm, lines: &[&DiagramNode], title: bool) -> Shape {
let mut body = TextBody::default();
body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
let top = lines.first().map(|n| if title { n.level } else { n.level.saturating_sub(1) }).unwrap_or(0);
for (k, n) in lines.iter().enumerate() {
let mut para = Paragraph::new(&n.text);
@@ -1913,7 +1913,7 @@ fn to_text(s: &mut Session, p: &Value) -> Result<Value> {
body.paragraphs.push(Paragraph::new(""));
}
body.body.wrap = Some(true);
body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?);
let tb =
Shape { id, name: "Text".into(), xfrm: Some(area), text_box: true, text: Some(body), geom: Geom::preset("rect"), ..Default::default() };
+4 -4
View File
@@ -288,9 +288,9 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
with_table(s, p, "table", move |t| {
match op.as_str() {
"rowAbove" => t.insert_row(r),
"rowBelow" => t.insert_row(r + 1),
"rowBelow" => t.insert_row(r.saturating_add(1)),
"colLeft" => t.insert_col(c),
"colRight" => t.insert_col(c + 1),
"colRight" => t.insert_col(c.saturating_add(1)),
"delRow" => {
if !t.delete_row(r) {
return Err(bad("table.deleteRow", "can't delete the last row"));
@@ -537,7 +537,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
with_table(s, p, "table.columnWidth", move |t| {
// Keeping the table's width, the column to the right gives or takes the difference (dragging
// an inner border). The last column has none: the table gets wider or narrower.
let pair = t.cols.get(c).zip(t.cols.get(c + 1)).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite());
let pair = t.cols.get(c).zip(t.cols.get(c.saturating_add(1))).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite());
match pair {
Some(pair) if keep_total => {
let min = MIN_COLUMN_WIDTH.min(pair / 2.0);
@@ -545,7 +545,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(x) = t.cols.get_mut(c) {
*x = w;
}
if let Some(x) = t.cols.get_mut(c + 1) {
if let Some(x) = t.cols.get_mut(c.saturating_add(1)) {
*x = pair - w;
}
}
+58 -3
View File
@@ -119,9 +119,14 @@ pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut T
let mut t = t0.clone();
let body = body_mut(doc, sel, &t).ok_or_else(|| bad("text", "the text being edited is gone"))?;
let r = f(body, &mut t);
// The saved shrink-on-overflow scale was for the old text: fit the new text afresh.
// The saved shrink-on-overflow scale was for the old text: fit the new text afresh. A
// body whose file carried no `fontScale` keeps none (100 %) here; `fit_shrink_scale`
// below fills in the computed scale when the new text overflows (#724).
if let Some(AutoFit::Shrink { font_scale, line_reduction }) = &mut body.body.autofit {
(*font_scale, *line_reduction) = (1.0, 0.0);
if font_scale.is_some() {
*font_scale = Some(1.0);
}
*line_reduction = 0.0;
}
sel.text = Some(t);
Ok(r)
@@ -132,8 +137,11 @@ pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut T
Ok(r)
}
/// Grow/shrink an auto-sizing text box (Resize shape to fit text) to its text.
/// Refit a shape after its text changed: remember the shrink-on-overflow scale the layout
/// computes (#724), then grow/shrink an auto-sizing text box (Resize shape to fit text) to its
/// text.
pub fn fit_text_box(s: &mut Session, id: ShapeId) -> Result<()> {
fit_shrink_scale(s, id)?;
let st = s.doc()?;
let Some(sh) = st.shape(id).cloned() else { return Ok(()) };
let Some(body) = sh.text.clone() else { return Ok(()) };
@@ -171,6 +179,53 @@ pub fn fit_text_box(s: &mut Session, id: ShapeId) -> Result<()> {
Ok(())
}
/// Store the shrink-on-overflow scale the layout computes for `id`, so a save keeps
/// `fontScale`/`lnSpcReduction` in step with what DeckCraft draws (#724). Runs after a text
/// change: a body with no meaningful saved scale — a file's bare `<a:normAutofit/>` (`None`) or
/// the reset an edit leaves (`1.0`, no reduction) — is fitted afresh; a scale the file saved is
/// left as it is, as PowerPoint draws it.
fn fit_shrink_scale(s: &mut Session, id: ShapeId) -> Result<()> {
let st = s.doc()?;
let Some(sh) = st.shape(id).cloned() else { return Ok(()) };
let Some(body) = sh.text.clone() else { return Ok(()) };
let Some(AutoFit::Shrink { font_scale, line_reduction }) = body.body.autofit else { return Ok(()) };
if !(font_scale.is_none() || (font_scale == Some(1.0) && line_reduction == 0.0)) {
return Ok(());
}
let doc = st.doc.clone();
let sel = st.selection.clone();
let Some(ctx) = ctx_for(&doc, &sel) else { return Ok(()) };
let x = xfrm_of(&doc, &sel, &sh);
let tr = deckcraft_render::shape_geometry(&sh, x.w, x.h).text_rect;
// Lay out as if the scale had just been reset, so the fit is computed even for `None` (which
// is drawn at 100 % and only fitted again when its text changes).
let mut probe = body.clone();
probe.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
let opts = deckcraft_text::Opts { rect: tr, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false };
let l = deckcraft_text::layout(&ctx, &sh, &probe, &opts);
if l.font_scale >= 1.0 && l.line_reduction <= 0.0 {
return Ok(());
}
// Change the body in place without an extra undo step (it is part of the edit).
let st = s.doc_mut()?;
let mut d = (*st.doc).clone();
let stored = if let Some(list) = crate::shapes_mut(&mut d, &st.selection)
&& let Some(target) = deckcraft_model::find_shape_mut(list, id)
&& let Some(t) = target.text.as_mut()
&& let Some(AutoFit::Shrink { font_scale, line_reduction }) = &mut t.body.autofit
{
(*font_scale, *line_reduction) = (Some(l.font_scale), l.line_reduction);
true
} else {
false
};
if stored {
st.doc = Arc::new(d);
st.revision += 1;
}
Ok(())
}
pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option<Ctx<'a>> {
match sel.target {
Target::Slides => doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)),
+4 -1
View File
@@ -311,7 +311,10 @@ mod tests {
let opened = open(&mut s2, &dir).unwrap();
assert!(s2.documents().iter().all(|d| d.uid != uid), "{opened:?}");
assert!(lock_path(&dir, other).exists(), "a live owner's lock stays");
// Once the owner is gone, its entry is recovered and its lock file cleaned up.
// Once the owner is gone, its entry is recovered and its lock file cleaned up. Unlock
// rather than only drop: a child spawned meanwhile by a parallel test (mkfifo) shares the
// open file and would keep the lock held until it execs.
lock.unlock().unwrap();
drop(lock);
let mut s3 = Session::new();
open(&mut s3, &dir).unwrap();
+135 -2
View File
@@ -94,6 +94,26 @@ fn text_editing_flow() {
assert_eq!(s.doc().unwrap().selection.shapes.len(), 1);
}
/// Cut with only a caret copies nothing, so it must not delete the character after the caret.
#[test]
fn cut_with_only_a_caret_changes_nothing() {
let mut s = session();
let r = s.execute("insert.textBox", &json!({"text": "Hello"})).unwrap();
let id = r["id"].as_u64().unwrap();
s.execute("text.edit", &json!({"id": id})).unwrap();
s.execute("text.move", &json!({"to": "start"})).unwrap();
let out = s.execute("edit.cut", &json!({})).unwrap();
assert_eq!(out["text"], "");
let t = s.execute("text.get", &json!({"id": id})).unwrap();
assert_eq!(t["text"], "Hello", "cut with only a caret changed the text");
// A real selection is still cut.
s.execute("text.move", &json!({"to": "end", "extend": true})).unwrap();
let out = s.execute("edit.cut", &json!({})).unwrap();
assert_eq!(out["text"], "Hello");
let t = s.execute("text.get", &json!({"id": id})).unwrap();
assert_eq!(t["text"], "");
}
#[test]
fn editing_text_refits_a_saved_shrink() {
use deckcraft_model::text::AutoFit;
@@ -105,13 +125,45 @@ fn editing_text_refits_a_saved_shrink() {
s.edit(|doc, sel| {
let shapes = crate::shapes_mut(doc, sel).unwrap();
let sh = deckcraft_model::find_shape_mut(shapes, title).unwrap();
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 0.5, line_reduction: 0.2 });
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.5), line_reduction: 0.2 });
Ok(())
})
.unwrap();
s.execute("text.insert", &json!({"text": "!"})).unwrap();
let sh = s.doc().unwrap().shape(title).cloned().unwrap();
assert_eq!(sh.text.unwrap().body.autofit, Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }));
assert_eq!(sh.text.unwrap().body.autofit, Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }));
}
/// After an edit, the shrink-on-overflow scale the layout computes is stored, so the saved file
/// carries the scale DeckCraft draws instead of a bare normAutofit (#724).
#[test]
fn editing_shrink_text_stores_the_layout_scale() {
use deckcraft_model::text::AutoFit;
let mut s = session();
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 200.0, 40.0]})).unwrap();
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
s.execute("format.autofit", &json!({"mode": "shrink"})).unwrap();
let text = (0..12).map(|i| format!("Line {i}")).collect::<Vec<_>>().join("\n");
s.execute("text.set", &json!({"id": id.0, "text": text})).unwrap();
let sh = s.doc().unwrap().shape(id).cloned().unwrap();
let Some(AutoFit::Shrink { font_scale, line_reduction }) = sh.text.unwrap().body.autofit else { panic!("shrink") };
assert!(font_scale.is_some_and(|s| s < 1.0), "the computed fit is stored: {font_scale:?} / {line_reduction}");
// The saved file must carry the scale DeckCraft draws, so PowerPoint opens it the same way.
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("ppt/slides/slide1.xml").unwrap(), &mut xml).unwrap();
let scales: Vec<i64> = xml.split("fontScale=\"").skip(1).filter_map(|s| s.split('"').next()?.parse().ok()).collect();
assert!(scales.iter().any(|v| *v < 100_000), "the fitted scale is saved: {scales:?}");
// Undo restores the state before the edit, and redo brings the stored fit back.
s.execute("edit.undo", &json!({})).unwrap();
let sh = s.doc().unwrap().shape(id).cloned().unwrap();
let Some(AutoFit::Shrink { font_scale, .. }) = sh.text.unwrap().body.autofit else { panic!("shrink") };
assert_eq!(font_scale, Some(1.0), "undo restores the scale the edit started from");
s.execute("edit.redo", &json!({})).unwrap();
let sh = s.doc().unwrap().shape(id).cloned().unwrap();
let Some(AutoFit::Shrink { font_scale, .. }) = sh.text.unwrap().body.autofit else { panic!("shrink") };
assert!(font_scale.is_some_and(|v| v < 1.0), "redo brings the stored fit back: {font_scale:?}");
}
#[test]
@@ -1425,6 +1477,41 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
}
/// #725: a layout with a small body placeholder ahead of its content placeholder (as Keynote
/// exports "Title & Bullets") still gets the body in the content placeholder (`idx="1"`).
#[test]
fn new_slide_body_goes_to_the_content_placeholder() {
for before in [true, false] {
let mut s = session();
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let id = deckcraft_model::ShapeId(doc.alloc_id().expect("id"));
let lay = std::sync::Arc::make_mut(&mut doc.masters[0])
.layouts
.iter_mut()
.find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent)
.unwrap();
let at = lay.shapes.iter().position(|x| x.ph.as_ref().is_some_and(|p| p.idx == 1)).unwrap();
let mut small = lay.shapes[at].clone();
small.id = id;
small.name = "Subtitle 21".into();
let ph = small.ph.as_mut().unwrap();
ph.kind = deckcraft_model::PhType::Body;
ph.idx = 21;
ph.size = Some("quarter".into());
lay.shapes.insert(if before { at } else { at + 1 }, small);
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "Line one\nLine two"})).unwrap();
let doc = &s.doc().unwrap().doc;
let slide = &doc.slides[doc.slides.len() - 1];
let text = |idx: u32| {
let sh = slide.shapes.iter().find(|x| x.ph.as_ref().is_some_and(|p| p.idx == idx)).unwrap();
sh.text.as_ref().map(|t| t.text()).unwrap_or_default()
};
assert_eq!(text(1), "Line one\nLine two", "before = {before}");
assert_eq!(text(21), "", "before = {before}");
}
}
#[test]
fn cell_fill_walks_only_the_cells_a_huge_selection_covers() {
// A selection may reach far past the table; filling it touches the real cells and nothing more.
@@ -3490,3 +3577,49 @@ fn an_exhausted_id_space_refuses_growth() {
sorted.dedup();
assert_eq!(sorted.len(), ids.len(), "no id repeats");
}
#[test]
fn shape_ids_beyond_u32_are_rejected_not_wrapped() {
let (mut s, a, b) = two_shapes();
let wrap = |id: deckcraft_model::ShapeId| u64::from(id.0) + (1u64 << 32);
let shapes_before = s.doc().unwrap().shapes().len();
let r = s.execute("shape.connect", &json!({"from": wrap(a), "to": wrap(b)}));
assert!(r.is_err(), "connect must reject ids beyond u32, got {r:?}");
let r = s.execute("shape.sites", &json!({"id": wrap(a)}));
assert!(r.is_err(), "sites must reject ids beyond u32, got {r:?}");
let r = s.execute("shape.points", &json!({"id": wrap(a)}));
assert!(r.is_err(), "points must reject ids beyond u32, got {r:?}");
let r = s.execute("shape.movePoint", &json!({"id": wrap(a), "index": 0, "x": 1.0, "y": 1.0}));
assert!(r.is_err_and(|e| e.to_string().contains("not a valid shape id")), "movePoint must reject ids beyond u32");
assert_eq!(s.doc().unwrap().shapes().len(), shapes_before, "no connector was created");
// In-range ids still work.
assert!(s.execute("shape.sites", &json!({"id": a.0})).is_ok());
assert!(s.execute("shape.connect", &json!({"from": a.0, "to": b.0})).is_ok());
}
#[test]
fn table_insert_below_or_right_of_a_huge_index_appends_at_the_end() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
let r = s.execute("table.insertRowBelow", &json!({"id": id.0, "row": u64::MAX}));
assert!(r.is_ok(), "insertRowBelow must clamp a huge row, got {r:?}");
let t = table_of(&s, id);
assert_eq!(t.n_rows(), 3);
assert_eq!(t.cell(0, 0).unwrap().text.text(), "a", "the original rows keep their place");
let r = s.execute("table.insertColumnRight", &json!({"id": id.0, "col": u64::MAX}));
assert!(r.is_ok(), "insertColumnRight must clamp a huge col, got {r:?}");
let t = table_of(&s, id);
assert_eq!(t.n_cols(), 3);
assert_eq!(t.cell(0, 1).unwrap().text.text(), "b", "the original columns keep their place");
}
#[test]
fn table_column_width_of_a_huge_column_is_ignored() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
for keep_total in [true, false] {
let r = s.execute("table.columnWidth", &json!({"id": id.0, "col": u64::MAX, "width": 50.0, "keepTotal": keep_total}));
assert!(r.is_ok(), "columnWidth must ignore a huge col (keepTotal {keep_total}), got {r:?}");
assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0], "no column changes");
}
}
+28
View File
@@ -59,6 +59,34 @@ fn start(p: &mut Presentation) {
p.slides.clear();
}
#[test]
fn saved_trigger_reveals_then_hides_on_separate_clicks() {
let mut p = defaults::new_presentation(None);
start(&mut p);
let button = shape(&mut p, "Button", "Toggle", 20.0, 20.0, 100.0, 40.0);
let target = shape(&mut p, "Target", "Answer", 200.0, 100.0, 200.0, 80.0);
let animations = vec![
Animation {
shape: target.id,
trigger: Some(button.id),
effect: "appear".into(),
start: AnimStart::OnClick,
duration_ms: 0,
..Default::default()
},
disappear_on_click(button.id, target.id),
];
add_slide(&mut p, vec![button, target], animations, false);
let mut show = Show::open(&p);
assert!(!show.shown("Target"));
for _ in 0..2 {
assert_eq!(show.click("Button"), Click::Animated);
assert!(show.shown("Target"), "first click reveals the answer until the next click");
assert_eq!(show.click("Button"), Click::Animated);
assert!(!show.shown("Target"), "second click hides the answer");
}
}
/// The slide show window's click handling, on a deck read from a PPTX file.
struct Show {
pres: Presentation,
+22 -1
View File
@@ -188,7 +188,12 @@ impl FontDb {
// A letter with marks the run's font lacks (stacked combining marks): a loaded plain
// face that draws the whole cluster, as PowerPoint draws the marks, before the run's
// font with missing-glyph boxes.
_ => self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned().unwrap_or_else(first),
_ => {
// Searched before `first` runs: it may load the default family, which needs the
// faces' write lock, so the read lock must not be held across it.
let found = self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned();
found.unwrap_or_else(first)
}
};
// A pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) whose first fallback is the
// colour emoji font: PowerPoint draws it from a plain symbol font.
@@ -277,6 +282,22 @@ mod tests {
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn falling_back_to_the_default_face_does_not_deadlock() {
// An empty chain and a character no loaded face draws: the loaded faces are searched,
// then the default family is looked up, which loads it (taking the faces' write lock).
// Holding the search's read lock across that lookup hung the thread.
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
let _ = tx.send(db.cascade(&['\u{10FFFD}'], &[], "Regular").family.clone());
});
let family = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("cascade did not return");
assert!(!family.is_empty());
}
#[test]
fn ligature_carets_never_fail() {
let face = FontDb::global().face(crate::DEFAULT_FAMILY, "Regular");
+28 -3
View File
@@ -1175,17 +1175,40 @@ impl FontDb {
None
}
/// A covering fallback accepted by the caller (for example, an interface that cannot use
/// colour or very large faces). Rejected faces do not stop the search for another face.
/// A policy-specific miss is not stored as a general document-font miss.
pub fn fallback_for_filtered(&self, c: char, exclude: u32, accept: impl Fn(&FontFace) -> bool) -> Option<Arc<FontFace>> {
if let Some(f) = self.loaded_fallback_filtered(c, exclude, &accept) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.system_fallback_filtered(c, &|f| f.id != exclude && accept(f), false) {
return self.loaded_fallback_filtered(c, exclude, &accept);
}
None
}
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
self.loaded_fallback_filtered(c, exclude, &|_| true)
}
fn loaded_fallback_filtered(&self, c: char, exclude: u32, accept: &impl Fn(&FontFace) -> bool) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
order.into_iter().find(|f| f.covers(c)).cloned()
order.into_iter().find(|f| f.covers(c) && accept(f)).cloned()
}
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
/// file under 40 MB). Returns true if one was loaded. Misses are remembered.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn system_fallback(&self, c: char) -> bool {
self.system_fallback_filtered(c, &|_| true, true)
}
#[cfg(not(target_arch = "wasm32"))]
fn system_fallback_filtered(&self, c: char, accept: &impl Fn(&FontFace) -> bool, remember_miss: bool) -> bool {
if c.is_control() || c.is_whitespace() {
return false;
}
@@ -1196,7 +1219,7 @@ impl FontDb {
return false;
}
}
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c));
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f));
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
// Another thread may have loaded a face for `c` (faces are only ever added), or found
@@ -1229,7 +1252,9 @@ impl FontDb {
return true;
}
}
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
if remember_miss {
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
}
false
}
+29
View File
@@ -226,6 +226,35 @@ mod tests {
assert!(!shape(&f, "Hello", &[], |c| c).is_empty());
}
#[test]
fn filtered_fallback_continues_after_a_rejected_loaded_face() {
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec()), 1);
let first = db.fallback_for('A', u32::MAX).unwrap();
let accepted_family = if first.family == LAST_RESORT_FAMILY { "DeckCraft Legacy" } else { LAST_RESORT_FAMILY };
let fallback = db.fallback_for_filtered('A', u32::MAX, |f| f.family == accepted_family).unwrap();
assert_eq!(fallback.family, accepted_family);
assert!(fallback.covers('A'));
assert_ne!(fallback.id(), first.id());
assert_eq!(db.fallback_for('A', u32::MAX).unwrap().id(), first.id(), "document fallback order is unchanged");
assert!(db.fallback_for_filtered('A', fallback.id(), |f| f.family == accepted_family).is_none(), "the excluded face stays excluded");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn filtered_fallback_loads_an_eligible_system_face_and_does_not_cache_policy_misses() {
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
let db = FontDb::with_font_dirs(vec![dir]);
let first = db.fallback_for('A', u32::MAX).unwrap();
assert_ne!(first.family, "DeckCraft Legacy");
assert!(db.fallback_for_filtered('A', u32::MAX, |_| false).is_none());
assert!(db.fallback_for('A', u32::MAX).is_some(), "an interface policy miss does not hide document glyphs");
let accepted = db.fallback_for_filtered('A', u32::MAX, |f| f.family == "DeckCraft Legacy").unwrap();
assert_eq!(accepted.family, "DeckCraft Legacy");
assert!(accepted.covers('A'));
}
#[test]
fn concurrent_lookups_all_find_an_installed_family() {
// Threads resolving an installed family nobody had loaded yet used to fall back to
+75 -2
View File
@@ -10,7 +10,7 @@ use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use deckcraft_model::text::{Paragraph, TextBody};
use deckcraft_model::{LayoutType, Presentation, defaults};
use deckcraft_model::{LayoutType, Presentation, ShapeKind, defaults};
pub const MIMETYPE: &str = "application/vnd.storyteller.deckcraft+zip";
/// Files written before the SlideCraft → DeckCraft rename (`.slidecraft`) are still read.
@@ -418,7 +418,12 @@ pub fn outline_to_slides(p: &mut Presentation, text: &str) -> usize {
let tabs = raw.chars().take_while(|c| *c == '\t').count();
let spaces = raw.chars().take_while(|c| *c == ' ').count() / 2;
let level = tabs.max(spaces);
let line = raw.trim().trim_start_matches(['-', '*', '•']).trim().to_string();
let t = raw.trim();
let line = match t.strip_prefix(['-', '*', '•']) {
Some(rest) if rest.starts_with([' ', '\t']) => rest.trim(),
_ => t,
}
.to_string();
if level == 0 {
slides.push((line, Vec::new()));
} else if let Some((_, body)) = slides.last_mut() {
@@ -505,6 +510,13 @@ pub fn slides_to_outline(p: &Presentation) -> String {
out.push('\n');
}
}
if let ShapeKind::Table(t) = &sh.kind {
for row in &t.rows {
let cells: Vec<String> = row.cells.iter().map(|c| c.text.text().replace(['\u{b}', '\n'], " ")).collect();
out.push_str(&cells.join("\t"));
out.push('\n');
}
}
}
}
out
@@ -690,6 +702,29 @@ mod tests {
}
}
/// A body whose file had no `fontScale` (`<a:normAutofit/>`) keeps that state through the
/// native format: it is drawn at 100 % and only fitted when its text changes (#724).
#[test]
fn a_bare_normautofit_body_round_trips() {
use deckcraft_model::text::{AutoFit, TextBody};
let mut p = Presentation::default();
let mut body = TextBody::from_text("Overflowing");
body.body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 });
let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() };
Arc::make_mut(&mut p.slides[0]).shapes.push(sh);
let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}"));
let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap();
assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }));
// A scale the file saved round-trips as itself.
let mut body = TextBody::from_text("Overflowing");
body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 });
let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() };
Arc::make_mut(&mut p.slides[0]).shapes.push(sh);
let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}"));
let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap();
assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 }));
}
/// Only `null`s where numbers belong are patched: other damage still fails.
#[test]
fn other_damage_is_still_reported() {
@@ -788,6 +823,15 @@ mod tests {
assert!(o.contains("Intro\n\tPoint one\n\t\tSub point\nSecond"), "{o}");
}
#[test]
fn outline_keeps_leading_dash_text() {
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
let n = outline_to_slides(&mut p, "-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\n- Bullets\n\t- Point\n\t• Other");
assert_eq!(n, 2);
let o = slides_to_outline(&p);
assert!(o.contains("-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\nBullets\n\tPoint\n\tOther"), "{o}");
}
#[test]
fn markdown_headings_and_list_items() {
assert_eq!(md_heading("# Intro"), Some("Intro"));
@@ -807,3 +851,32 @@ mod tests {
}
}
}
/// #814: slides_to_outline includes table cell text, which it used to skip.
#[test]
fn outline_includes_table_text() {
use deckcraft_model::table::{Cell, Row, Table};
use deckcraft_model::{Shape, ShapeKind, Xfrm};
let mut p = defaults::new_presentation(None);
let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title()));
let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() };
body.text = Some(TextBody::from_text("Text box body"));
s.shapes.push(body);
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] {
let cells: Vec<Cell> = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect();
table.rows.push(Row { height: 30.0, cells });
}
let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() };
tbl.kind = ShapeKind::Table(table);
s.shapes.push(tbl);
p.slides = vec![std::sync::Arc::new(s)];
let out = slides_to_outline(&p);
assert!(out.contains("Text box body"), "{out}");
assert!(out.contains("Cell A1\tCell B1"), "{out}");
assert!(out.contains("Cell A2\tCell B2"), "{out}");
}
+164 -15
View File
@@ -1,11 +1,15 @@
//! Where MCP tool calls end up: a control-channel method call.
use std::io::{BufRead, BufReader, Write};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
use serde_json::{Value, json};
// Includes the newline, matching the control server's request budget. In particular a peer
// must not be able to grow a reply indefinitely by withholding its newline.
const MAX_REPLY_BYTES: u64 = 16 * 1024 * 1024;
/// Something that answers control-channel methods (`engine.execute`, `document.inspect`,
/// `ui.pointer`, `ui.render`, …). See `deckcraft_ui_egui::control` for the full list.
pub trait Backend {
@@ -58,7 +62,7 @@ impl Remote {
/// Write one request line. A failure here means the app never got a complete line, so retrying is safe.
/// A connection the app has already closed (it restarted since the last call) is replaced before writing.
fn send(&mut self, line: &str) -> std::io::Result<()> {
if self.conn.as_ref().is_none_or(|(_, writer)| is_closed(writer)) {
if self.conn.as_ref().is_none_or(|(reader, writer)| !reader.buffer().is_empty() || is_closed(writer)) {
self.reconnect()?;
}
let Some((_, writer)) = self.conn.as_mut() else {
@@ -69,15 +73,34 @@ impl Remote {
}
/// Read the reply to a request that was already sent.
fn receive(&mut self) -> std::io::Result<String> {
fn receive(&mut self, id: u64) -> std::io::Result<Result<Value, String>> {
let Some((reader, _)) = self.conn.as_mut() else {
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
};
let mut reply = String::new();
if reader.read_line(&mut reply)? == 0 {
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
let mut reply = Vec::new();
reader.take(MAX_REPLY_BYTES + 1).read_until(b'\n', &mut reply)?;
if reply.len() as u64 > MAX_REPLY_BYTES {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "control reply exceeds 16 MiB"));
}
if reply.last() != Some(&b'\n') {
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control reply has no terminating newline"));
}
let invalid = |message| std::io::Error::new(std::io::ErrorKind::InvalidData, message);
let v: Value = serde_json::from_slice(&reply).map_err(|e| invalid(format!("bad reply from app: {e}")))?;
if v.get("id").and_then(Value::as_u64) != Some(id) {
return Err(invalid("control reply has an unexpected request id".to_string()));
}
match v.get("ok").and_then(Value::as_bool) {
Some(true) if v.get("error").is_none() => {
v.get("result").cloned().map(Ok).ok_or_else(|| invalid("successful control reply has no result".to_string()))
}
Some(false) if v.get("result").is_none() => v
.get("error")
.and_then(Value::as_str)
.map(|error| Err(error.to_string()))
.ok_or_else(|| invalid("failed control reply has no error string".to_string())),
_ => Err(invalid("control reply has an invalid envelope".to_string())),
}
Ok(reply)
}
}
@@ -107,19 +130,13 @@ impl Backend for Remote {
format!("DeckCraft app at {} is not reachable: {e}", self.addr)
})?;
}
let reply = self.receive().map_err(|e| {
self.receive(id).map_err(|e| {
self.conn = None;
format!(
"lost the reply from the DeckCraft app at {} ({e}); the command may or may not have run, check the document before retrying",
self.addr
)
})?;
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
if v.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
Err(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string())
}
})?
}
fn has_ui(&self) -> bool {
@@ -138,6 +155,138 @@ mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
fn reply_once(reply: Vec<u8>) -> (Remote, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr").to_string();
let peer = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
stream.set_read_timeout(Some(Duration::from_secs(5))).expect("timeout");
stream.set_write_timeout(Some(Duration::from_secs(5))).expect("write timeout");
let mut line = String::new();
BufReader::new(&stream).read_line(&mut line).expect("request");
assert_eq!(serde_json::from_str::<Value>(&line).expect("JSON")["id"], 1);
// An oversized reply may be rejected before the peer finishes writing it.
let _ = stream.write_all(&reply);
});
(Remote::connect(&addr).expect("connect"), peer)
}
#[test]
fn wrong_reply_id_is_not_command_success() {
let (mut remote, peer) = reply_once(b"{\"id\":999,\"ok\":true,\"result\":\"unrelated\"}\n".to_vec());
let result = remote.call("engine.execute", json!({"command":"slide.new"}));
peer.join().expect("peer");
let error = result.expect_err("an unrelated reply cannot confirm a mutation");
assert!(error.contains("may or may not have run"), "{error}");
assert!(remote.conn.is_none(), "discard the invalid connection without replaying");
}
#[test]
fn matching_success_and_error_replies_keep_their_meaning() {
for (reply, expected) in [
("{\"id\":1,\"ok\":true,\"result\":null}\r\n", Ok(Value::Null)),
("{\"id\":1,\"ok\":true,\"result\":{\"slides\":2}}\n", Ok(json!({"slides":2}))),
("{\"id\":1,\"ok\":false,\"error\":\"disabled\"}\n", Err("disabled".into())),
] {
let (mut remote, peer) = reply_once(reply.as_bytes().to_vec());
assert_eq!(remote.call("engine.execute", json!({})), expected);
assert!(remote.conn.is_some(), "valid responses do not invalidate the connection");
peer.join().expect("peer");
}
}
#[test]
fn malformed_reply_envelopes_leave_the_outcome_unknown() {
for reply in [
"not JSON\n",
"[]\n",
"{\"ok\":true,\"result\":null}\n",
"{\"id\":\"1\",\"ok\":true,\"result\":null}\n",
"{\"id\":1,\"ok\":1,\"result\":null}\n",
"{\"id\":1,\"ok\":true}\n",
"{\"id\":1,\"ok\":false}\n",
"{\"id\":1,\"ok\":false,\"error\":42}\n",
"{\"id\":1,\"ok\":true,\"result\":null,\"error\":\"failed\"}\n",
"{\"id\":1,\"ok\":false,\"result\":null,\"error\":\"failed\"}\n",
] {
let (mut remote, peer) = reply_once(reply.as_bytes().to_vec());
let result = remote.call("engine.execute", json!({}));
peer.join().expect("peer");
let error = result.expect_err(reply);
assert!(error.contains("may or may not have run"), "{reply}: {error}");
assert!(remote.conn.is_none(), "{reply}: discard the invalid connection");
}
}
#[test]
fn eof_without_a_newline_is_not_a_complete_reply() {
let (mut remote, peer) = reply_once(b"{\"id\":1,\"ok\":true,\"result\":null}".to_vec());
let result = remote.call("engine.execute", json!({}));
peer.join().expect("peer");
assert!(result.is_err(), "a truncated frame cannot confirm a mutation");
assert!(remote.conn.is_none());
}
#[test]
fn oversized_reply_is_rejected() {
let reply = format!("{{\"id\":1,\"ok\":true,\"result\":\"{}\"}}\n", "x".repeat(16 * 1024 * 1024));
let (mut remote, peer) = reply_once(reply.into_bytes());
let result = remote.call("engine.execute", json!({}));
let invalidated = remote.conn.is_none();
// Close the socket before joining: the bounded reader need not drain the peer's reply.
remote.conn = None;
peer.join().expect("peer");
assert!(result.is_err(), "a reply is limited to 16 MiB");
let error = result.expect_err("checked above");
assert!(error.contains("may or may not have run"), "{error}");
assert!(invalidated, "discard the oversized reply's connection");
}
#[test]
fn reply_at_the_size_limit_is_accepted() {
let prefix = "{\"id\":1,\"ok\":true,\"result\":\"";
let suffix = "\"}\n";
let payload_len = 16 * 1024 * 1024 - prefix.len() - suffix.len();
let reply = format!("{prefix}{}{suffix}", "x".repeat(payload_len));
let (mut remote, peer) = reply_once(reply.into_bytes());
let result = remote.call("document.inspect", json!({})).expect("reply at the limit");
assert_eq!(result.as_str().map(str::len), Some(payload_len));
peer.join().expect("peer");
}
#[test]
fn buffered_unsolicited_reply_is_discarded_before_sending() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr").to_string();
let peer = std::thread::spawn(move || {
let (mut first, _) = listener.accept().expect("first connection");
first.set_read_timeout(Some(Duration::from_secs(5))).expect("timeout");
let mut line = String::new();
BufReader::new(&first).read_line(&mut line).expect("initial request");
// Even a stale frame whose ID happens to match must not answer a new request.
first
.write_all(b"{\"id\":1,\"ok\":true,\"result\":\"first\"}\n{\"id\":2,\"ok\":true,\"result\":\"stale\"}\n")
.expect("two replies together");
line.clear();
let sent_on_stale = BufReader::new(&first).read_line(&mut line).expect("first request") != 0;
if !sent_on_stale {
let (mut fresh, _) = listener.accept().expect("fresh connection");
fresh.set_read_timeout(Some(Duration::from_secs(5))).expect("timeout");
BufReader::new(&fresh).read_line(&mut line).expect("fresh request");
fresh.write_all(b"{\"id\":2,\"ok\":false,\"error\":\"rejected\"}\n").expect("actual reply");
}
sent_on_stale
});
let mut remote = Remote::connect(&addr).expect("connect");
assert_eq!(remote.call("document.inspect", json!({})), Ok(json!("first")));
// Force the stale frame into BufReader, where a socket-only peek cannot see it.
assert!(!remote.conn.as_mut().expect("connection").0.fill_buf().expect("buffer").is_empty());
let result = remote.call("engine.execute", json!({"command":"slide.new"}));
let sent_on_stale = peer.join().expect("peer");
assert_eq!(result, Err("rejected".into()), "only the fresh peer's response counts");
assert!(!sent_on_stale, "replace a connection with buffered unsolicited bytes before sending");
}
#[test]
fn lost_reply_is_not_resent() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
+26 -3
View File
@@ -92,9 +92,15 @@ impl Container {
} else if at(0, &ASF_HEADER) {
Container::Asf
} else if at(0, b"ID3") {
// ID3v2 is followed by MP3 frames (or, rarely, ADTS).
let size = b.get(6..10).map(|s| s.iter().fold(0usize, |a, &x| (a << 7) | (x & 0x7f) as usize)).unwrap_or(0);
match Container::sniff(b.get(10 + size..).unwrap_or(&[])) {
// ID3v2 is followed by MP3 frames (or, rarely, ADTS). The chain of tags is walked
// iteratively — a crafted file can carry thousands of empty tags — and only the
// bytes after the last one are sniffed.
let mut rest = b;
while rest.starts_with(b"ID3") {
let size = rest.get(6..10).map(|s| s.iter().fold(0usize, |a, &x| (a << 7) | (x & 0x7f) as usize)).unwrap_or(0);
rest = rest.get(10 + size..).unwrap_or(&[]);
}
match Container::sniff(rest) {
Container::Adts => Container::Adts,
_ => Container::Mp3,
}
@@ -126,3 +132,20 @@ impl Container {
/// Worker threads are available (not on wasm32).
pub(crate) const THREADS: bool = !cfg!(target_arch = "wasm32");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id3_tag_chains_do_not_recurse_the_stack() {
// 2 MB of empty ID3v2 tags (10 bytes each) chained ahead of the MP3 frames: walking
// the chain must not cost one stack frame per tag.
let mut b = Vec::with_capacity(200_000 * 10 + 2);
for _ in 0..200_000 {
b.extend_from_slice(&[b'I', b'D', b'3', 4, 0, 0, 0, 0, 0, 0]);
}
b.extend_from_slice(&[0xFF, 0xFB]); // an MP3 frame sync
assert_eq!(Container::sniff(&b), Container::Mp3);
}
}
+4 -4
View File
@@ -203,13 +203,13 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
let title_box = |b: &mut B| {
let (x, y, ww, hh) = r(60.0, 29.0, 840.0, 104.0);
let mut s = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh);
s.text = Some(body_with(Some(Anchor::Middle), Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 })));
s.text = Some(body_with(Some(Anchor::Middle), Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 })));
s
};
let body_box = |b: &mut B, name: &str, idx: u32, x: f64, y: f64, ww: f64, hh: f64| {
let (x, y, ww, hh) = r(x, y, ww, hh);
let mut s = b.shape(name, PhType::Body, idx, x, y, ww, hh);
s.text = Some(body_with(Some(Anchor::Top), Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 })));
s.text = Some(body_with(Some(Anchor::Top), Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 })));
s
};
@@ -263,7 +263,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
t.text = Some(TextBody {
body: BodyProps {
anchor: Some(Anchor::Bottom),
autofit: Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }),
autofit: Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }),
..Default::default()
},
list_style: ls,
@@ -288,7 +288,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
st.text = Some(TextBody {
body: BodyProps {
anchor: Some(Anchor::Top),
autofit: Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }),
autofit: Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }),
..Default::default()
},
list_style: ls,
+37
View File
@@ -227,6 +227,29 @@ impl Table {
if r1 >= self.rows.len() || c1 >= self.cols.len() || (r0 == r1 && c0 == c1) {
return false;
}
// Grow the rectangle until no existing merge sticks out of it.
let (mut r0, mut c0, mut r1, mut c1) = (r0, c0, r1, c1);
loop {
let before = (r0, c0, r1, c1);
for (r, row) in self.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate().take(self.cols.len()) {
if cell.is_covered() {
continue;
}
let ar1 = r.saturating_add((cell.row_span.max(1) as usize).saturating_sub(1)).min(self.rows.len() - 1);
let ac1 = c.saturating_add((cell.grid_span.max(1) as usize).saturating_sub(1)).min(self.cols.len() - 1);
if r <= r1 && ar1 >= r0 && c <= c1 && ac1 >= c0 {
r0 = r0.min(r);
c0 = c0.min(c);
r1 = r1.max(ar1);
c1 = c1.max(ac1);
}
}
}
if (r0, c0, r1, c1) == before {
break;
}
}
let mut text = vec![];
for r in r0..=r1 {
for c in c0..=c1 {
@@ -505,6 +528,20 @@ mod tests {
t.cell(r, c).unwrap().text.text()
}
#[test]
fn merging_over_an_existing_merge_takes_it_in() {
let mut t = Table::new(3, 3, 300.0, 30.0);
if let Some(c) = t.cell_mut(1, 2) {
c.text = TextBody::from_text("X");
}
assert!(t.merge(1, 1, 1, 2));
assert!(t.merge(0, 0, 1, 1));
assert_topology(&t);
let a = t.cell(0, 0).unwrap();
assert_eq!((a.row_span, a.grid_span), (2, 3));
assert_eq!(anchor_text(&t, 0, 0), "X");
}
#[test]
fn row_and_column_edits_keep_merges_consistent() {
for at in 0..=4 {
+8 -2
View File
@@ -41,8 +41,14 @@ impl Anchor {
pub enum AutoFit {
/// Do not autofit.
None,
/// Shrink text on overflow: font scale and line-spacing reduction (0–1).
Shrink { font_scale: f64, line_reduction: f64 },
/// Shrink text on overflow: font scale and line-spacing reduction (0–1). `font_scale` is
/// `None` for a file's `<a:normAutofit/>` with no `fontScale`: the schema default 100 %,
/// which PowerPoint draws as it is and only fits again when the text changes (#724).
Shrink {
#[serde(skip_serializing_if = "Option::is_none")]
font_scale: Option<f64>,
line_reduction: f64,
},
/// Resize shape to fit text.
Shape,
}
+11 -4
View File
@@ -3,7 +3,7 @@ name = "deckcraft-pdf"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DeckCraft PDF export (krilla): slides, notes pages and handouts with a selectable text layer, links and bookmarks"
description = "DeckCraft PDF export (craft-pdf/Krilla): slides, notes pages and handouts with a selectable text layer, links and bookmarks"
[lints]
workspace = true
@@ -13,13 +13,20 @@ deckcraft-model = { workspace = true }
deckcraft-fonts = { workspace = true }
deckcraft-text = { workspace = true }
deckcraft-render = { workspace = true }
krilla = { workspace = true }
craft-pdf = { workspace = true }
kurbo = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
[dev-dependencies]
hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] }
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3"
# Off the web build, krilla compresses each slide's artwork on a background thread while the next
# slide renders (the same bytes, in parallel).
# slide renders (the same bytes, in parallel). The root patch selects the same pinned
# backend as craft-pdf; this dependency only enables its native compression feature.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
krilla = { workspace = true, features = ["rayon"] }
krilla = { version = "=0.8.2", features = ["rayon"] }
+34
View File
@@ -0,0 +1,34 @@
# DeckCraft PDF export
DeckCraft uses `craft-pdf` for writer construction, metadata, fallible font preparation and
final serialization. Slide rasterization, the selectable text overlay, font discovery and
shaping, hyperlinks, bookmarks, notes and handout layout remain app-owned. The existing
renderer also resolves and rasterizes WMF/EMF pictures before export.
Only the shared `write` feature is enabled, with default features disabled. Krilla retains
its raster-image and simple-text features, including notes/footer shaping. PDF embedding is
disabled: DeckCraft does not use it. The production exporter has no Hayro parser, interpreter,
image-decoder, fallback-font or CMap dependencies. Tests use `hayro-syntax` 0.7.2 directly,
with only `std` enabled, to inspect the generated PDFs.
The exporter runs inside a native unwind guard. Writer failures return `PdfError::Write`.
A font that cannot be prepared for embedding now produces an error instead of silently
omitting its selectable text or notes. The shared Krilla 0.8.2 patch avoids a second panic
in page/surface cleanup while unwinding and permanently poisons a document after cleanup
failure, so a retained writer cannot serialize it. Successful serialization paths are
unchanged. Native unwind guards cannot catch WASM aborts or process-wide allocation failures.
## Building and validation
Rust 1.95 or newer is required. The workspace pins `craft-pdf` 0.1.0 and its Krilla 0.8.2
patch to `https://github.com/storytold/craft-libs` revision
`39adbdf756569c8e03adb747d5e063f468ca98c1`. Both packages resolve from that immutable Git source;
a sibling checkout is unnecessary. Publish and review this shared-library prerequisite
before publishing the app migration, so fresh CI and release builds can fetch it.
Run `cargo test -p deckcraft-pdf` for export contracts and `cargo xtask ci` for all workspace
gates. PDF contract tests also run with `wasm-bindgen-test-runner` on
`wasm32-unknown-unknown`. The `pdf-deps` gate verifies the two writer source pins,
retained Krilla image/text features and the development-only parser boundary.
This migration does not add vector slide artwork or change text-shaping/ToUnicode policy.
+54 -30
View File
@@ -8,8 +8,13 @@
//! Layouts: one slide per page, notes pages (slide above its speaker notes) and handouts (1, 2, 3,
//! 4, 6 or 9 slides per page).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::collections::HashMap;
use craft_pdf::write::{self, Metadata, Writer, backend as krilla};
use deckcraft_fonts::FontDb;
use deckcraft_model::Presentation;
use deckcraft_model::text::Action as ClickAction;
@@ -19,7 +24,6 @@ use krilla::annotation::{Annotation, LinkAnnotation, Target};
use krilla::destination::XyzDestination;
use krilla::geom::{Point, Size, Transform};
use krilla::image::Image;
use krilla::metadata::Metadata;
use krilla::num::NormalizedF32;
use krilla::outline::{Outline, OutlineNode};
use krilla::page::PageSettings;
@@ -37,6 +41,12 @@ pub enum PdfError {
Write(String),
}
impl From<write::WriteError> for PdfError {
fn from(error: write::WriteError) -> Self {
Self::Write(error.to_string())
}
}
pub type Result<T> = std::result::Result<T, PdfError>;
/// What each page shows.
@@ -118,6 +128,10 @@ pub fn notes_slide_box(paper: (f64, f64), aspect: f64) -> Rect {
/// Export `pres` as PDF bytes.
pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
write::guard(|| export_inner(pres, opts))
}
fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
let slides: Vec<usize> = match &opts.slides {
Some(v) => v.iter().copied().filter(|i| *i < pres.slides.len()).collect(),
None => (0..pres.slides.len()).filter(|i| opts.include_hidden || !pres.slides[*i].hidden).collect(),
@@ -125,16 +139,16 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
if slides.is_empty() {
return Err(PdfError::NoPages);
}
let mut pdf = krilla::Document::new();
let title = opts.title.clone().unwrap_or_else(|| pres.props.title.clone());
let mut meta = Metadata::new().creator("DeckCraft".into()).producer("DeckCraft".into());
if !title.trim().is_empty() {
meta = meta.title(title);
}
if !pres.props.author.trim().is_empty() {
meta = meta.authors(vec![pres.props.author.clone()]);
}
pdf.set_metadata(meta);
let metadata = Metadata {
creator: "DeckCraft".into(),
producer: "DeckCraft".into(),
title: (!title.trim().is_empty()).then_some(title),
authors: if pres.props.author.trim().is_empty() { Vec::new() } else { vec![pres.props.author.clone()] },
..Default::default()
};
let mut writer = Writer::new(Default::default(), metadata);
let pdf = writer.document_mut();
let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0));
let mut ex = Exporter { pres, opts, fonts: HashMap::new() };
@@ -174,16 +188,16 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
{
let mut s = page.surface();
for (i, b) in v {
ex.slide(&mut s, *i, *b);
ex.slide(&mut s, *i, *b)?;
annots.extend(ex.links(*i, *b, &page_of));
}
match opts.layout {
PageLayout::Notes => {
if let Some((i, _)) = v.first() {
let notes = pres.slides[*i].notes_text();
ex.paragraphs(&mut s, &notes, notes_frames(pres, opts.paper).1, 12.0);
ex.paragraphs(&mut s, &notes, notes_frames(pres, opts.paper).1, 12.0)?;
}
ex.page_texts(&mut s, pres.notes_master.as_deref(), pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1));
ex.page_texts(&mut s, pres.notes_master.as_deref(), pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1))?;
}
PageLayout::Handouts { per_page } => {
if per_page == 3 {
@@ -191,7 +205,7 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
ex.note_lines(&mut s, Rect::new(b.x1 + 24.0, b.y0, pw - 54.0, b.y1));
}
}
ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1));
ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1))?;
}
PageLayout::Slides => {}
}
@@ -202,7 +216,7 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
}
page.finish();
}
pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))
writer.finish().map_err(Into::into)
}
/// A notes-size master's rect of placeholder `kind`, scaled to a page of `paper` points.
@@ -368,7 +382,7 @@ pub fn handout_boxes(per_page: u8, paper: (f64, f64), aspect: f64) -> Vec<Rect>
struct Exporter<'a> {
pres: &'a Presentation,
opts: &'a PdfOptions,
fonts: HashMap<u32, Option<Font>>,
fonts: HashMap<u32, Font>,
}
fn tf(a: Affine) -> Transform {
@@ -385,12 +399,17 @@ fn grey(v: u8, opacity: f32) -> Fill {
}
impl Exporter<'_> {
fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Option<Font> {
self.fonts.entry(face.id()).or_insert_with(|| Font::new(face.data().to_vec().into(), face.index())).clone()
fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Result<Font> {
if let Some(font) = self.fonts.get(&face.id()) {
return Ok(font.clone());
}
let font = write::font(face.data(), face.index(), None)?;
self.fonts.insert(face.id(), font.clone());
Ok(font)
}
/// Slide `i` drawn into `b` (page points).
fn slide(&mut self, s: &mut Surface, i: usize, b: Rect) {
fn slide(&mut self, s: &mut Surface, i: usize, b: Rect) -> Result<()> {
let pres = self.pres;
let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0));
let k = b.width() / sw;
@@ -432,14 +451,15 @@ 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(pres, i).texts {
self.text_layer(s, &pt, view, false);
self.text_layer(s, &pt, view, false)?;
}
let _ = sh;
}
Ok(())
}
/// Invisible glyphs exactly over the rasterised text.
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) {
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> {
for run in &pt.layout.runs {
if run.glyphs.is_empty() || run.size <= 0.0 {
continue;
@@ -457,7 +477,7 @@ impl Exporter<'_> {
if text.trim().is_empty() {
continue;
}
let Some(font) = self.font(&run.face) else { continue };
let font = self.font(&run.face)?;
let n = run.glyphs.len();
let ranges = glyph_text_ranges(&text, run.chars, &run.cells, n);
let (x0, y0) = (run.glyphs[0].1, run.glyphs[0].2);
@@ -486,6 +506,7 @@ impl Exporter<'_> {
s.set_fill(None);
s.pop();
}
Ok(())
}
fn links(&self, i: usize, b: Rect, page_of: &HashMap<usize, usize>) -> Vec<Annotation> {
@@ -516,15 +537,15 @@ impl Exporter<'_> {
out
}
fn ui_font(&mut self) -> Option<(Font, std::sync::Arc<deckcraft_fonts::FontFace>)> {
fn ui_font(&mut self) -> Result<(Font, std::sync::Arc<deckcraft_fonts::FontFace>)> {
let face = FontDb::global().face(deckcraft_fonts::DEFAULT_FAMILY, "Regular");
let f = self.font(&face)?;
Some((f, face))
Ok((f, face))
}
/// Wrapped plain text (notes).
fn paragraphs(&mut self, s: &mut Surface, text: &str, area: Rect, size: f64) {
let Some((font, face)) = self.ui_font() else { return };
fn paragraphs(&mut self, s: &mut Surface, text: &str, area: Rect, size: f64) -> Result<()> {
let (font, face) = self.ui_font()?;
let k = size / face.upem.max(1.0);
let width = |w: &str| w.chars().map(|c| face.advance(face.glyph_for(c)) * k).sum::<f64>();
let lh = size * 1.3;
@@ -554,6 +575,7 @@ impl Exporter<'_> {
y += lh;
}
s.set_fill(None);
Ok(())
}
/// Ruled lines for handwritten notes (3-per-page handouts).
@@ -578,21 +600,23 @@ impl Exporter<'_> {
/// A notes or handout page's header, date, footer and page number from its master; just the
/// page number when the deck has no such master.
fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) {
fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) -> Result<()> {
let Some(m) = master else { return self.footer(s, pw, ph, n) };
// Laid out by the text engine in the placeholders, scaled from the notes size to the page:
// wrapping, alignment and mixed formatting as the master has them.
let pres = self.pres;
let view = Affine::scale_non_uniform(pw / pres.notes_size.width.max(1.0), ph / pres.notes_size.height.max(1.0));
for pt in deckcraft_render::place_master_texts(pres, m, n) {
self.text_layer(s, &pt, view, true);
self.text_layer(s, &pt, view, true)?;
}
Ok(())
}
fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) {
let Some((font, _)) = self.ui_font() else { return };
fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) -> Result<()> {
let (font, _) = self.ui_font()?;
s.set_fill(Some(grey(0x60, 1.0)));
s.draw_text(Point::from_xy((pw - 72.0) as f32, (ph - 30.0) as f32), font, 9.0, &n.to_string(), false, TextDirection::Auto);
s.set_fill(None);
Ok(())
}
}
+216 -5
View File
@@ -24,33 +24,49 @@ fn opts(layout: PageLayout) -> PdfOptions {
PdfOptions { layout, dpi: 48.0, ..Default::default() }
}
#[test]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn exports_slides_notes_and_handouts() {
let p = deck(5);
for layout in [PageLayout::Slides, PageLayout::Notes, PageLayout::Handouts { per_page: 3 }, PageLayout::Handouts { per_page: 6 }] {
let bytes = export(&p, &opts(layout)).expect("export");
assert!(bytes.starts_with(b"%PDF-"), "{layout:?}");
assert!(bytes.len() > 1000);
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let (pages, size) = match layout {
PageLayout::Slides => (5, (960.0, 540.0)),
PageLayout::Notes => (5, (612.0, 792.0)),
PageLayout::Handouts { per_page: 3 } => (2, (612.0, 792.0)),
_ => (1, (612.0, 792.0)),
};
assert_eq!(pdf.pages().len(), pages);
assert!(pdf.pages().iter().all(|page| page.render_dimensions() == size));
}
}
#[test]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn empty_range_is_an_error() {
let p = deck(1);
let o = PdfOptions { slides: Some(vec![7]), ..opts(PageLayout::Slides) };
assert!(matches!(export(&p, &o), Err(PdfError::NoPages)));
}
#[test]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn hidden_slides_skipped_unless_asked() {
let mut p = deck(2);
Arc::make_mut(&mut p.slides[1]).hidden = true;
let a = export(&p, &opts(PageLayout::Slides)).unwrap();
let b = export(&p, &PdfOptions { include_hidden: true, ..opts(PageLayout::Slides) }).unwrap();
assert!(b.len() > a.len());
assert_eq!(hayro_syntax::Pdf::new(a.clone()).unwrap().pages().len(), 1);
assert_eq!(hayro_syntax::Pdf::new(b.clone()).unwrap().pages().len(), 2);
let ranged = export(&p, &PdfOptions { slides: Some(vec![1, 0, 9]), ..opts(PageLayout::Slides) }).unwrap();
assert_eq!(hayro_syntax::Pdf::new(ranged.clone()).unwrap().pages().len(), 2);
}
#[test]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn handout_boxes_fit_the_paper() {
for n in [1, 2, 3, 4, 6, 9] {
let v = handout_boxes(n, (612.0, 792.0), 16.0 / 9.0);
@@ -67,6 +83,201 @@ fn handout_boxes_fit_the_paper() {
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn metadata_retains_values_and_omits_blank_fields() {
let mut p = deck(1);
p.props.title = " Deck title ".into();
p.props.author = " Author ".into();
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
assert_eq!(pdf.metadata().title.as_deref(), Some(b" Deck title ".as_slice()));
assert_eq!(pdf.metadata().author.as_deref(), Some(b" Author ".as_slice()));
assert_eq!(pdf.metadata().creator.as_deref(), Some(b"DeckCraft".as_slice()));
assert_eq!(pdf.metadata().producer.as_deref(), Some(b"DeckCraft".as_slice()));
assert!(pdf.metadata().creation_date.is_none());
p.props.author = " ".into();
let bytes = export(&p, &PdfOptions { title: Some(" ".into()), ..opts(PageLayout::Slides) }).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
assert!(pdf.metadata().title.is_none());
assert!(pdf.metadata().author.is_none());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn text_layer_links_and_bookmarks_survive_serialization() {
use deckcraft_model::text::{Action, Hyperlink};
use hayro_syntax::object::{Array, Dict};
let mut p = deck(2);
let last = p.slides[1].id;
let slide = Arc::make_mut(&mut p.slides[0]);
for (shape, action) in slide.shapes.iter_mut().zip([Action::Url { url: "https://example.com/deck".into() }, Action::Slide { slide: last }]) {
shape.click = Some(Hyperlink { action, tooltip: "Link".into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None });
}
for text_layer in [true, false] {
let bytes = export(&p, &PdfOptions { text_layer, ..opts(PageLayout::Slides) }).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let page = &pdf.pages()[0];
let annotations = page.raw().get::<Array<'_>>(b"Annots").unwrap();
assert_eq!(annotations.iter::<Dict<'_>>().count(), 2);
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
let fonts = resources.get::<Dict<'_>>(b"Font");
assert_eq!(fonts.is_some_and(|fonts| !fonts.is_empty()), text_layer);
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).unwrap();
let outlines = catalog.get::<Dict<'_>>(b"Outlines").unwrap();
let first = outlines.get::<Dict<'_>>(b"First").unwrap();
assert!(first.get::<Dict<'_>>(b"Next").is_some());
}
}
/// Build a small damaged derivative of our openly licensed last-resort font entirely in
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
/// embedding. No font bytes or vendor assets are stored as test fixtures.
fn font_without_head() -> Vec<u8> {
let mut bytes = FontDb::global().face("Ubuntu", "Regular").data().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() {
if bytes[offset..offset + old.len()] == *old {
bytes[offset..offset + old.len()].copy_from_slice(new);
offset += old.len();
} else {
offset += 1;
}
}
}
let tables = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
for i in 0..tables {
let offset = 12 + i * 16;
if &bytes[offset..offset + 4] == b"head" {
bytes[offset..offset + 4].copy_from_slice(b"zzzz");
}
}
bytes
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
use deckcraft_model::{Shape, ShapeId, Xfrm};
let db = FontDb::global();
assert_eq!(db.add_font(font_without_head()), 1);
assert_eq!(db.face("BadPDF", "Regular").family, "BadPDF");
let mut p = deck(1);
let slide = Arc::make_mut(&mut p.slides[0]);
let mut body = TextBody::from_text("Text that must not disappear silently");
for paragraph in &mut body.paragraphs {
for run in &mut paragraph.runs {
run.props.font = Some("BadPDF".into());
run.props.size = Some(12.0);
}
}
slide.shapes = vec![Shape { id: ShapeId(9000), xfrm: Some(Xfrm::new(20.0, 20.0, 500.0, 200.0)), text: Some(body), ..Default::default() }];
let error = export(&p, &opts(PageLayout::Slides)).unwrap_err();
assert!(matches!(error, PdfError::Write(_)));
assert!(error.to_string().contains("font could not be read for embedding"));
// Master text remains visible even when the optional slide text layer is off.
// It must propagate the same embedding error through notes and handout pages.
let mut header = ph(deckcraft_model::PhType::Header, 0.0, 0.0, 500.0, 40.0, "");
header.text = p.slides[0].shapes[0].text.clone();
for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 3 }] {
let mut p = deck(1);
let master = Some(Arc::new(deckcraft_model::Master { shapes: vec![header.clone()], ..Default::default() }));
if layout == PageLayout::Notes {
p.notes_master = master;
} else {
p.handout_master = master;
}
let error = export(&p, &PdfOptions { text_layer: false, ..opts(layout) }).unwrap_err();
assert!(matches!(error, PdfError::Write(_)));
assert!(error.to_string().contains("font could not be read for embedding"), "{layout:?}: {error}");
}
// The failed export leaves no poisoned writer state behind.
assert!(export(&deck(1), &opts(PageLayout::Slides)).is_ok());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[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");
assert_eq!(face.family, "Ubuntu");
assert!(text.chars().all(|c| face.glyph_for(c) != 0), "missing: {:?}", text.chars().filter(|c| face.glyph_for(*c) == 0).collect::<Vec<_>>());
let mut body = TextBody::from_text(text);
for paragraph in &mut body.paragraphs {
for run in &mut paragraph.runs {
run.props.font = Some("Ubuntu".into());
run.props.size = Some(20.0);
}
}
let mut p = deck(1);
Arc::make_mut(&mut p.slides[0]).shapes =
vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body), ..Default::default() }];
let placed = deckcraft_render::place_slide(&p, 0);
let runs: Vec<_> = placed.texts.iter().flat_map(|placed| &placed.layout.runs).collect();
assert!(!runs.is_empty());
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();
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);
let font = fonts.get::<Dict<'_>>(fonts.keys().next().unwrap()).unwrap();
let cmap = font.get::<Stream<'_>>(b"ToUnicode").unwrap().decoded().unwrap().into_owned();
let cmap = std::str::from_utf8(&cmap).unwrap();
// This fixture uses the writer's two-byte CIDs and bfchar mappings. Decode the
// content's text-showing operators through that map, not document metadata.
let mut mapping = HashMap::<u16, String>::new();
let mut in_char_map = false;
for line in cmap.lines() {
if line.ends_with("beginbfchar") {
in_char_map = true;
} else if line == "endbfchar" {
in_char_map = false;
} else if in_char_map {
let parts: Vec<_> = line.split_whitespace().collect();
let cid = u16::from_str_radix(parts[0].trim_matches(['<', '>']), 16).unwrap();
let value = parts[1].trim_matches(['<', '>']);
let units: Vec<_> = value
.as_bytes()
.as_chunks::<4>()
.0
.iter()
.map(|chunk| u16::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap())
.collect();
mapping.insert(cid, String::from_utf16(&units).unwrap());
}
}
let mut extracted = String::new();
let mut append = |bytes: &[u8]| {
assert!(bytes.len().is_multiple_of(2));
for pair in bytes.as_chunks::<2>().0 {
extracted.push_str(mapping.get(&u16::from_be_bytes([pair[0], pair[1]])).unwrap());
}
};
let mut operations = page.typed_operations();
while let Some(operation) = operations.next() {
match operation {
TypedInstruction::ShowText(value) => append(value.0.as_bytes()),
TypedInstruction::ShowTexts(values) => {
for value in values.0.iter::<Object<'_>>() {
if let Object::String(value) = value {
append(value.as_bytes());
}
}
}
_ => {}
}
}
assert_eq!(extracted, text);
}
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
/// on the page and room left for the notes.
#[test]
+8 -2
View File
@@ -338,7 +338,7 @@ pub fn body_pr(b: &El) -> BodyProps {
match e.local() {
"normAutofit" => {
p.autofit = Some(AutoFit::Shrink {
font_scale: pct(e, "fontScale").unwrap_or(1.0).clamp(0.01, 1.0),
font_scale: pct(e, "fontScale").map(|v| v.clamp(0.01, 1.0)),
line_reduction: pct(e, "lnSpcReduction").unwrap_or(0.0).clamp(0.0, 1.0),
})
}
@@ -645,8 +645,14 @@ mod tests {
assert_eq!(b.wrap, Some(false));
assert_eq!(b.vert, Some(TextDir::Vertical270));
assert_eq!(b.columns, Some(2));
assert_eq!(b.autofit, Some(AutoFit::Shrink { font_scale: 0.625, line_reduction: 0.2 }));
assert_eq!(b.autofit, Some(AutoFit::Shrink { font_scale: Some(0.625), line_reduction: 0.2 }));
assert_eq!(b.space_first_last, None);
// A bare `<a:normAutofit/>` keeps no scale: the schema default 100 % is drawn as it is,
// not the same as a saved 100 % (`fontScale="100000"`) (#724).
let d = parse(br#"<a:bodyPr><a:normAutofit/></a:bodyPr>"#).unwrap();
assert_eq!(body_pr(&d.root).autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }));
let d = parse(br#"<a:bodyPr><a:normAutofit fontScale="100000"/></a:bodyPr>"#).unwrap();
assert_eq!(body_pr(&d.root).autofit, Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }));
let d = parse(br#"<a:bodyPr spcFirstLastPara="1"/>"#).unwrap();
assert_eq!(body_pr(&d.root).space_first_last, Some(true));
// mongolianVert is its own direction (PowerPoint runs its lines left to right) and is
+4 -2
View File
@@ -489,9 +489,11 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
Some(AutoFit::None) => w.empty0("a:noAutofit"),
Some(AutoFit::Shape) => w.empty0("a:spAutoFit"),
Some(AutoFit::Shrink { font_scale, line_reduction }) => {
let fs = pct(font_scale.clamp(0.01, 1.0));
// A scale the model holds is written, 100 % included: reopening then keeps the same
// "draw the saved scale" state instead of becoming a bare `<a:normAutofit/>` (#724).
let fs = font_scale.map(|s| pct(s.clamp(0.01, 1.0)));
let lr = pct(line_reduction.clamp(0.0, 1.0));
w.empty("a:normAutofit", A::new().o("fontScale", (fs != 100_000).then_some(fs)).o("lnSpcReduction", (lr != 0).then_some(lr)));
w.empty("a:normAutofit", A::new().o("fontScale", fs).o("lnSpcReduction", (lr != 0).then_some(lr)));
}
None => {}
}
+4 -15
View File
@@ -655,21 +655,10 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
w.close("p:seq");
}
for (trig, effs) in triggered {
// Everything a trigger starts plays from its click.
let effs: Vec<Eff> = effs
.into_iter()
.enumerate()
.map(|(i, e)| Eff {
start: if i == 0 {
AnimStart::OnClick
} else if e.start == AnimStart::OnClick {
AnimStart::AfterPrevious
} else {
e.start
},
..e
})
.collect();
// The first step starts with the button's click. Later On Click effects must remain
// separate steps; With/After Previous effects still belong to their preceding step.
let effs: Vec<Eff> =
effs.into_iter().enumerate().map(|(i, e)| Eff { start: if i == 0 { AnimStart::OnClick } else { e.start }, ..e }).collect();
let clicks = group(effs);
let trig_cond = |w: &mut W| {
w.open("p:cond", A::new().a("evt", "onClick").a("delay", 0));
+2 -2
View File
@@ -109,7 +109,7 @@ fn corpus() -> Presentation {
let mut columns = TextBody::from_text("Two columns\nof vertical text");
columns.body.columns = Some(3);
columns.body.col_spacing = Some(18.0);
columns.body.autofit = Some(AutoFit::Shrink { font_scale: 0.85, line_reduction: 0.2 });
columns.body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.85), line_reduction: 0.2 });
columns.body.vert = Some(TextDir::Vertical270);
let mut col = shape(&mut p, "Columns", 480.0, 40.0, 200.0, 160.0);
col.text = Some(columns);
@@ -176,7 +176,7 @@ fn chart_table_and_text_corpus_round_trips() {
assert_eq!(plain(cols), "Two columns\nof vertical text");
assert_eq!(cols.body.columns, Some(3));
assert_eq!(cols.body.col_spacing, Some(18.0));
assert_eq!(cols.body.autofit, Some(AutoFit::Shrink { font_scale: 0.85, line_reduction: 0.2 }));
assert_eq!(cols.body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.85), line_reduction: 0.2 }));
assert_eq!(cols.body.vert, Some(TextDir::Vertical270));
let warp = by_name(slide, "Warp").text.as_ref().expect("warp");
+2 -2
View File
@@ -79,7 +79,7 @@ fn style_deck() -> Presentation {
let mut word = shape(&mut p, "Styled text", 40.0, 40.0, 400.0, 180.0);
word.text = Some(TextBody {
body: BodyProps {
autofit: Some(AutoFit::Shrink { font_scale: 0.8, line_reduction: 0.2 }),
autofit: Some(AutoFit::Shrink { font_scale: Some(0.8), line_reduction: 0.2 }),
columns: Some(2),
col_spacing: Some(12.0),
vert: Some(TextDir::Vertical270),
@@ -167,7 +167,7 @@ fn style_kinds_round_trip() {
let twice = round(&once);
let body = &by_name(&once, "Styled text").text.as_ref().expect("text").body;
assert_eq!(body.autofit, Some(AutoFit::Shrink { font_scale: 0.8, line_reduction: 0.2 }));
assert_eq!(body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.8), line_reduction: 0.2 }));
assert_eq!(body.columns, Some(2));
assert_eq!(body.col_spacing, Some(12.0));
assert_eq!(body.vert, Some(TextDir::Vertical270));
+95
View File
@@ -0,0 +1,95 @@
//! Synthetic interactive sequences must keep one step per click across a save.
use std::io::{Cursor, Read};
use std::sync::Arc;
use deckcraft_geom::Xfrm;
use deckcraft_model::anim::{AnimStart, Animation, TextBuild};
use deckcraft_model::text::TextBody;
use deckcraft_model::{Presentation, Shape, ShapeId, defaults};
fn deck() -> Presentation {
let mut p = defaults::new_presentation(None);
let shapes: Vec<_> = ["Button", "Other button", "Content"]
.into_iter()
.map(|name| Shape {
id: ShapeId(p.alloc_id().expect("id")),
name: name.into(),
xfrm: Some(Xfrm::new(40.0, 40.0, 200.0, 100.0)),
text: Some(TextBody::from_text("First\nSecond\nThird")),
..Default::default()
})
.collect();
Arc::make_mut(&mut p.slides[0]).shapes = shapes;
p
}
fn round(p: &Presentation) -> Presentation {
deckcraft_pptx::import(&deckcraft_pptx::export(p).expect("export")).expect("import")
}
fn slide_xml(p: &Presentation) -> String {
let bytes = deckcraft_pptx::export(p).expect("export");
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut xml = String::new();
z.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("xml");
xml
}
#[test]
fn triggered_click_steps_survive_import_edit_save_reopen() {
use AnimStart::{AfterPrevious, OnClick, WithPrevious};
let mut p = deck();
let ids: Vec<_> = p.slides[0].shapes.iter().map(|s| s.id).collect();
let starts = [OnClick, WithPrevious, AfterPrevious, OnClick, WithPrevious, OnClick];
for (i, start) in starts.into_iter().enumerate() {
Arc::make_mut(&mut p.slides[0]).animations.push(Animation {
shape: ids[2],
trigger: Some(ids[0]),
start,
effect: "appear".into(),
delay_ms: i as u32 * 100,
..Default::default()
});
}
// An independent button and the main sequence must not borrow this button's steps.
for trigger in [Some(ids[1]), None] {
for start in [OnClick, AfterPrevious, OnClick] {
Arc::make_mut(&mut p.slides[0]).animations.push(Animation { shape: ids[2], trigger, start, ..Default::default() });
}
}
let mut q = round(&p);
for pass in 0..2 {
for trigger in [Some(ids[0]), Some(ids[1]), None] {
let state = |deck: &Presentation| {
deck.slides[0].animations.iter().filter(|a| a.trigger == trigger).map(|a| (a.start, a.delay_ms)).collect::<Vec<_>>()
};
assert_eq!(state(&q), state(&p), "trigger {trigger:?}, pass {pass}");
}
assert_eq!(q.slides[0].animations.len(), p.slides[0].animations.len());
Arc::make_mut(&mut q.slides[0]).shapes[2].name = "Edited content".into();
q = round(&q);
}
let xml = slide_xml(&p);
assert_eq!(xml.matches("nodeType=\"interactiveSeq\"").count(), 2);
assert_eq!(xml.matches("nodeType=\"clickEffect\"").count(), 7);
// The first step in each interactive sequence begins at zero; subsequent steps wait.
assert_eq!(xml.matches("delay=\"indefinite\"").count(), 5);
}
#[test]
fn triggered_paragraph_build_keeps_a_click_for_each_paragraph() {
let mut p = deck();
let ids: Vec<_> = p.slides[0].shapes.iter().map(|s| s.id).collect();
Arc::make_mut(&mut p.slides[0]).animations =
vec![Animation { shape: ids[2], trigger: Some(ids[0]), text_build: TextBuild::ByParagraph, start: AnimStart::OnClick, ..Default::default() }];
for q in [p.clone(), round(&p)] {
let xml = slide_xml(&q);
assert_eq!(xml.matches("nodeType=\"clickEffect\"").count(), 3);
assert_eq!(xml.matches("delay=\"indefinite\"").count(), 2);
let back = round(&q);
assert_eq!(back.slides[0].animations.len(), 1);
assert_eq!(back.slides[0].animations[0].text_build, TextBuild::ByParagraph);
assert_eq!(back.slides[0].animations[0].trigger, Some(ids[0]));
}
}
+29 -3
View File
@@ -222,6 +222,9 @@ pub struct RenderOpts<'a> {
pub skip: &'a [ShapeId],
/// Hide the text of this shape (the UI draws it while editing).
pub skip_text: Option<ShapeId>,
/// Text being edited: replace its prompt with gray markers on empty list paragraphs.
/// Ignored outside editor rendering.
pub editing_text: Option<ShapeId>,
/// Animated GIF pictures and how far into their play they are (seconds); others show frame 1.
pub gif_times: &'a [(ShapeId, f64)],
/// Worker threads (0 = this thread; required when effects use filters).
@@ -246,6 +249,7 @@ impl Default for RenderOpts<'_> {
state: None,
skip: &[],
skip_text: None,
editing_text: None,
gif_times: &[],
threads: 0,
offset: (0.0, 0.0),
@@ -921,7 +925,7 @@ impl Renderer {
}
ShapeKind::Table(t) => {
// Rows grow with their text: what the cells cover, borders included.
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields);
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields, f.opts.edit && f.opts.editing_text == Some(s.id));
self.track(m, cells.unwrap_or(Rect::new(0.0, 0.0, x.w, x.h)).inflate(2.0, 2.0));
}
ShapeKind::Chart(c) => {
@@ -1300,8 +1304,21 @@ impl Renderer {
&text_geo
};
let tr = geo.text_rect;
if !body.is_empty() {
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
let editing = f.opts.edit && f.opts.editing_text == Some(s.id);
if !body.is_empty() || editing {
// Imported placeholders can have no paragraph until the first keystroke.
let mut empty;
let body = if editing && body.paragraphs.is_empty() {
empty = body.clone();
empty.paragraphs.push(Default::default());
&empty
} else {
body
};
let mut l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
if editing {
preview_empty_bullets(&mut l);
}
let tm = text_transform(m, tr, l.rotation);
for b in l.lines.iter().filter_map(line_bounds) {
// Glyphs reach a little past their carets and line box (italics, swashes).
@@ -2034,6 +2051,15 @@ pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody
draw_layout(ctx, &l, tr, m);
}
/// Add transient list markers to an editor layout, using the document's own bullet glyphs.
fn preview_empty_bullets(l: &mut deckcraft_text::TextLayout) {
for mut run in l.empty_bullets.drain(..) {
run.color = Rgba::rgb(0xB3, 0xB3, 0xB3);
run.alpha = 1.0;
l.runs.push(run);
}
}
/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local →
/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre.
///
+12 -10
View File
@@ -188,16 +188,6 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
return;
};
let px_scale = crate::scale_of(m);
let want = (bounds.width().max(bounds.height()) * px_scale).max(1.0);
let frame = gif_time.and_then(|t| images::gif_pixmap(&item.data, &pf.adjust, t));
let Some(pm) = frame.or_else(|| images::decode_for(&item.data, want, &pf.adjust)) else {
missing(ctx, bounds);
return;
};
let (iw, ih) = (pm.width() as f64, pm.height() as f64);
if iw < 1.0 || ih < 1.0 {
return;
}
let dest = match &pf.mode {
PictureMode::Stretch { fill_rect } => {
let [l, t, r, b] = *fill_rect;
@@ -211,6 +201,18 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
}
PictureMode::Tile { .. } => bounds,
};
// Decode for the whole image's size on screen, not the box's: a cropped picture shows only part
// of it, magnified (#762).
let want = (dest.width().abs().max(dest.height().abs()) * px_scale).max(1.0);
let frame = gif_time.and_then(|t| images::gif_pixmap(&item.data, &pf.adjust, t));
let Some(pm) = frame.or_else(|| images::decode_for(&item.data, want, &pf.adjust)) else {
missing(ctx, bounds);
return;
};
let (iw, ih) = (pm.width() as f64, pm.height() as f64);
if iw < 1.0 || ih < 1.0 {
return;
}
let alpha = pf.alpha.unwrap_or(1.0).clamp(0.0, 1.0);
let image = vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
+7 -2
View File
@@ -327,6 +327,7 @@ pub fn draw(
_w: f64,
_h: f64,
fields: &dyn deckcraft_text::Fields,
editing: bool,
) -> Option<kurbo::Rect> {
let _ = pres;
let rects = cell_rects(t, &row_heights(rctx, t, fields));
@@ -418,14 +419,18 @@ pub fn draw(
// Text.
for (r, c, rect) in &rects {
let Some(cell) = t.cell(*r, *c) else { continue };
if cell.text.is_empty() || text_off_target(cell, *rect, m, (ctx.width(), ctx.height())) {
if (cell.text.is_empty() && !editing) || text_off_target(cell, *rect, m, (ctx.width(), ctx.height())) {
continue;
}
let body = cell_body(rctx, t, *r, *c, cell);
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
#[cfg(test)]
TEXT_CELLS_DRAWN.with(|n| n.set(n.get() + 1));
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
let mut l = deckcraft_text::layout(rctx, &tmp, &body, &deckcraft_text::Opts { rect: *rect, fields, prompt_color: None, no_shrink: false });
if editing {
crate::preview_empty_bullets(&mut l);
}
crate::draw_layout(ctx, &l, *rect, m);
let _ = peniko::Fill::NonZero;
}
covered
+59
View File
@@ -2509,3 +2509,62 @@ fn single_series_column_chart_varies_colours_by_point() {
};
assert_ne!(render(true), render(false));
}
/// A 1024×1024 PNG of 2 px wide black and white vertical stripes.
fn fine_stripes_png() -> Vec<u8> {
let img =
image::RgbaImage::from_fn(1024, 1024, |x, _| if (x / 2) % 2 == 0 { image::Rgba([0, 0, 0, 255]) } else { image::Rgba([255, 255, 255, 255]) });
let mut out = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).expect("png");
out.into_inner()
}
#[test]
fn a_cropped_picture_is_decoded_at_the_resolution_of_the_whole_image() {
// The top-left sixteenth of the image in a 64 pt box: 64 image pixels over 64 output pixels.
// Decoding at the box's size instead of the whole image's blurs the stripes to grey (#762).
let mut p = Presentation::default();
let media = p.add_media("stripes.png", "image/png", fine_stripes_png()).expect("media");
let fill = deckcraft_model::style::PictureFill { media, crop: [0.0, 0.0, 15.0 / 16.0, 15.0 / 16.0], ..Default::default() };
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 64.0, 64.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let (mut sharp, mut total) = (0, 0);
for y in 110..154 {
for x in 110..154 {
let v = img.pixel(x, y)[0];
total += 1;
if !(48..=208).contains(&v) {
sharp += 1;
}
}
}
assert!(sharp * 10 >= total * 9, "the stripes stay sharp: {sharp} of {total} pixels black or white");
}
#[test]
fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
use deckcraft_model::text::{Bullet, Paragraph};
let mut para = Paragraph::default();
para.props.bullet = Some(Bullet::Char { char: "•".into() });
para.props.margin_left = Some(24.0);
para.props.indent = Some(-24.0);
para.end_props.size = Some(32.0);
let sh = Shape {
id: ShapeId(901),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 120.0)),
text: Some(TextBody { paragraphs: vec![para], ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![sh]);
let render = |edit, editing_text| render_slide(&p, 0, &RenderOpts { edit, editing_text, ..Default::default() });
let plain = render(false, None);
assert_eq!(plain.pixels, render(true, None).pixels);
assert_eq!(plain.pixels, render(false, Some(ShapeId(901))).pixels, "export ignores editor markers");
assert_eq!(plain.pixels, render(true, Some(ShapeId(902))).pixels, "other shapes stay empty");
let editing = render(true, Some(ShapeId(901)));
let changed: Vec<_> = editing.pixels.as_chunks::<4>().0.iter().zip(plain.pixels.as_chunks::<4>().0).filter(|(a, b)| a != b).collect();
assert!(!changed.is_empty(), "an empty bullet must be visible");
assert!(changed.iter().any(|(px, _)| px[0] == 179 && px[1] == 179 && px[2] == 179));
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
}
+1
View File
@@ -105,6 +105,7 @@ fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> {
state: o.state,
skip: o.skip,
skip_text: o.skip_text,
editing_text: o.editing_text,
gif_times: o.gif_times,
threads: o.threads,
offset: o.offset,
+78 -56
View File
@@ -292,6 +292,9 @@ pub struct LineInfo {
#[derive(Clone, Debug, Default)]
pub struct TextLayout {
pub runs: Vec<GlyphRun>,
/// Empty list markers for the editor only; ordinary rendering and export omit them.
/// Their space is still reserved so the caret starts where the first character will.
pub empty_bullets: Vec<GlyphRun>,
pub decos: Vec<Deco>,
pub lines: Vec<LineInfo>,
/// Inner text box (insets applied), shape-local.
@@ -739,56 +742,52 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
let lead_size = lead_style.size;
let lead_face = lead_style.face.clone();
// Bullet.
let bullet = if para.is_empty() && opts.prompt_color.is_none() {
None
} else {
match &props.bullet {
Some(Bullet::Char { char }) => {
let mut rp = lead.clone();
if let Some(f) = &props.bullet_font {
rp.font = Some(f.clone());
}
rp.bold = Some(false);
rp.italic = Some(false);
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.underline = None;
rp.strike = None;
rp.highlight = None;
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
st.baseline = 0.0;
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
bullet_face(db, &mut st, &ch);
let bullet = match &props.bullet {
Some(Bullet::Char { char }) => {
let mut rp = lead.clone();
if let Some(f) = &props.bullet_font {
rp.font = Some(f.clone());
}
rp.bold = Some(false);
rp.italic = Some(false);
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.underline = None;
rp.strike = None;
rp.highlight = None;
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
st.baseline = 0.0;
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
bullet_face(db, &mut st, &ch);
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
// at U+F000 + the character code as such fonts map them; otherwise common symbol
// bullets are mapped to Unicode look-alikes.
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
Some(c) => c.to_string(),
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
};
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
st.face = db.face("Inter", "Regular");
}
Some((ch, st))
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
// at U+F000 + the character code as such fonts map them; otherwise common symbol
// bullets are mapped to Unicode look-alikes.
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
Some(c) => c.to_string(),
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
};
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
st.face = db.face("Inter", "Regular");
}
Some(Bullet::AutoNum { scheme, start_at }) => {
let mut rp = lead.clone();
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.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)));
bullet_face(db, &mut st, &label);
Some((label, st))
}
_ => None,
Some((ch, st))
}
Some(Bullet::AutoNum { scheme, start_at }) => {
let mut rp = lead.clone();
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.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)));
bullet_face(db, &mut st, &label);
Some((label, st))
}
_ => None,
};
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
}
@@ -1549,18 +1548,21 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay
let autofit = bp.autofit.unwrap_or(AutoFit::None);
// Shrink on overflow starts from the scale the file was saved with (`fontScale`,
// `lnSpcReduction`), which is what PowerPoint draws; editing the text resets it.
let (scale0, reduce0) = match (autofit, opts.no_shrink) {
let (scale0, reduce0, no_saved_scale) = match (autofit, opts.no_shrink) {
(AutoFit::Shrink { font_scale, line_reduction }, false) => (
if font_scale.is_finite() { font_scale.clamp(0.1, 1.0) } else { 1.0 },
font_scale.filter(|s| s.is_finite()).map(|s| s.clamp(0.1, 1.0)).unwrap_or(1.0),
if line_reduction.is_finite() { line_reduction.clamp(0.0, 0.2) } else { 0.0 },
font_scale.is_none(),
),
_ => (1.0, 0.0),
_ => (1.0, 0.0, false),
};
let mut l = layout_scaled(ctx, shape, body, &bp, opts, scale0, reduce0);
// A scale PowerPoint saved is drawn as it is: PowerPoint fits the text again only when it
// changes (and editing resets the saved scale), not when a file opens, even where our
// metrics would call it overflowing (trailing empty paragraphs, substituted fonts).
let saved = scale0 < 1.0 || reduce0 > 0.0;
// A body whose file has no `fontScale` at all (`<a:normAutofit/>`) is the schema default
// 100 % and is drawn as it is too, not refitted on open (#724).
let saved = no_saved_scale || scale0 < 1.0 || reduce0 > 0.0;
if let (AutoFit::Shrink { .. }, false) = (autofit, opts.no_shrink)
&& l.overflow
&& !saved
@@ -1654,6 +1656,10 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
*deeper = 0;
}
numbering.get(lvl).copied()
} else if matches!(pp.bullet, Some(Bullet::AutoNum { .. })) {
// Preview the next number without consuming it: empty paragraphs do not advance
// numbering in the saved document or affect later populated paragraphs.
numbering.get(lvl).map(|n| n.saturating_add(1))
} else {
if !para.is_empty() {
for n in numbering.iter_mut().skip(lvl) {
@@ -1908,9 +1914,20 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
// PowerPoint draws justified and distributed anchoring at the bottom.
Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h,
};
// anchorCtr: centre the block horizontally.
let dx = if bp.anchor_ctr.unwrap_or(false) { ((inner.width() - out.content_width) / 2.0).max(0.0) } else { 0.0 };
let ox = inner.x0 + dx;
// anchorCtr: centre the drawn text block on the box. Lines sit where their alignment put
// them, so the origin is set so the block's centre lands on the box's centre — a left-edge
// offset alone pushed right-aligned lines out of the box (#736). Empty lines carry no ink
// and stay out of the block. Overflowing content centres symmetrically past both edges.
// Not applied to vertical text (there the offset would run along the wrong axis) or
// multi-column bodies (centring unimplemented for them).
let centre_block = bp.anchor_ctr.unwrap_or(false) && ncols == 1 && matches!(vert, TextDir::Horizontal);
let ox = if centre_block && !all_lines.is_empty() {
let ink = all_lines.iter().filter(|(li, ..)| li.end > li.start);
let (bmin, bmax) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), (li, ..)| (a.min(li.extent.0), b.max(li.extent.1)));
if bmax >= bmin { inner.x0 + inner.width() / 2.0 - (bmin + bmax) / 2.0 } else { inner.x0 }
} else {
inner.x0
};
let oy = inner.y0 + dy;
// Emit runs, in visual order.
for (line, place, _, bullet) in all_lines.iter_mut() {
@@ -1930,7 +1947,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
&& let Some((_, st)) = p.bullet.as_ref()
{
let (_, g) = measure_str(&st.face, st.size, &s);
out.runs.push(GlyphRun {
let run = GlyphRun {
face: st.face.clone(),
size: st.size,
color: st.color,
@@ -1946,7 +1963,12 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
gradient: None,
effects: None,
upright: false,
});
};
if body.paragraphs.get(line.para).is_some_and(|p| p.is_empty()) && opts.prompt_color.is_none() {
out.empty_bullets.push(run);
} else {
out.runs.push(run);
}
}
// Group visually consecutive cells by (style, face).
let mut cur: Option<GlyphRun> = None;
+143 -6
View File
@@ -186,26 +186,38 @@ fn saved_shrink_scale_is_drawn() {
// A file saved with fontScale 62.5% and lnSpcReduction 20% draws at that scale even though
// the text would fit at full size, as PowerPoint draws it.
let (p, mut sh) = setup("Short", 600.0, 300.0);
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 0.625, line_reduction: 0.2 });
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.625), line_reduction: 0.2 });
let l = lay(&p, &sh, 600.0, 300.0);
assert_eq!((l.font_scale, l.line_reduction), (0.625, 0.2));
// Text our metrics find overflowing at the saved scale is still drawn at it, as PowerPoint
// draws it until the text changes; with no saved scale it shrinks to fit.
let text = (0..30).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
let (p, mut sh) = setup(&text, 600.0, 200.0);
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 0.9, line_reduction: 0.1 });
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.9), line_reduction: 0.1 });
let l = lay(&p, &sh, 600.0, 200.0);
assert_eq!((l.font_scale, l.line_reduction), (0.9, 0.1));
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
let l = lay(&p, &sh, 600.0, 200.0);
assert!(l.font_scale < 0.9, "{}", l.font_scale);
// Nonsense saved values are ignored.
let (p, mut sh) = setup("Short", 600.0, 300.0);
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: f64::NAN, line_reduction: 5.0 });
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(f64::NAN), line_reduction: 5.0 });
let l = lay(&p, &sh, 600.0, 300.0);
assert_eq!((l.font_scale, l.line_reduction), (1.0, 0.2));
}
/// A body whose file has `<a:normAutofit/>` with no `fontScale` is the schema default 100 %:
/// PowerPoint draws it as it is, even overflowing. DeckCraft draws it the same (#724).
#[test]
fn bare_normautofit_draws_at_full_size() {
let text = (0..30).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
let (p, mut sh) = setup(&text, 600.0, 200.0);
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 });
let l = lay(&p, &sh, 600.0, 200.0);
assert_eq!(l.font_scale, 1.0, "a body with no saved fontScale draws at 100%");
assert!(l.overflow, "it overflows, as PowerPoint draws it");
}
#[test]
fn exact_line_spacing_ends_at_the_last_descent() {
// 12 pt text at exactly 26.59 pt per line (a table cell PowerPoint sizes to 24.3 pt of text):
@@ -942,8 +954,11 @@ fn symbol_font_characters_draw_in_the_sym_font() {
// U+F0DE is the Symbol font's double arrow (⇒). It must come from that font, not from
// whichever installed font happens to map U+F0DE (Wingdings draws an arrow at another angle).
let symbol = "Symbol";
if !FontDb::global().has_family(symbol) {
eprintln!("skipped: no Symbol font installed");
let db = FontDb::global();
// Some installed Symbol faces use Unicode mappings only, without the legacy U+F0xx cmap.
// This integration test needs a face that can draw the symbol code used by the document.
if !db.has_family(symbol) || !db.face(symbol, "Regular").covers('\u{F0DE}') {
eprintln!("skipped: no installed Symbol face covers U+F0DE");
return;
}
let (p, mut sh) = setup("x\u{F0DE}y", 600.0, 300.0);
@@ -1330,3 +1345,125 @@ fn stacked_combining_marks_take_a_face_that_draws_them() {
}
let _ = f;
}
/// #736: `anchorCtr` centres the text block horizontally, whatever the paragraphs' own
/// alignment, and overflowing content centres symmetrically past both edges.
#[test]
fn anchorctr_centres_the_text_block() {
let text = "The shape is top - center - overriding paragraph right alignment.";
// The block's centre: (min left + max right) over the laid-out lines, in box coordinates.
let block_centre = |l: &TextLayout| -> f64 {
// Empty lines carry no ink and sit where their alignment left them: not part of the block.
let ink = l.lines.iter().filter(|li| li.end > li.start);
let (min, max) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), li| (a.min(li.extent.0), b.max(li.extent.1)));
(min + max) / 2.0
};
let para = |align: Option<Align>, text: &str| Paragraph {
props: ParaProps { align, ..Default::default() },
runs: vec![Run::new(text)],
..Default::default()
};
let anchored =
|paragraphs: Vec<Paragraph>| TextBody { body: BodyProps { anchor_ctr: Some(true), ..Default::default() }, paragraphs, ..Default::default() };
// One right-aligned line in a wide box: the block is centred, not pushed past the right edge.
let body_right = anchored(vec![para(Some(Align::Right), text)]);
let (p, mut sh) = setup("", 828.0, 104.0);
sh.text = Some(body_right);
let l = lay(&p, &sh, 828.0, 104.0);
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "right-aligned: centre {}, want 414", block_centre(&l));
// Left-aligned text centres the same way.
let body_left = anchored(vec![para(None, text)]);
let (p, mut sh) = setup("", 828.0, 104.0);
sh.text = Some(body_left);
let l = lay(&p, &sh, 828.0, 104.0);
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "left-aligned: centre {}, want 414", block_centre(&l));
// A trailing empty paragraph carries no ink and must not skew the block.
let body_empty = anchored(vec![para(Some(Align::Right), text), para(None, "")]);
let (p, mut sh) = setup("", 828.0, 104.0);
sh.text = Some(body_empty);
let l = lay(&p, &sh, 828.0, 104.0);
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "trailing empty paragraph: centre {}, want 414", block_centre(&l));
// Mixed alignments: the drawn block (not either alignment edge) is what centres.
let body_mixed = anchored(vec![para(Some(Align::Right), text), para(None, "short")]);
let (p, mut sh) = setup("", 828.0, 104.0);
sh.text = Some(body_mixed);
let l = lay(&p, &sh, 828.0, 104.0);
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "mixed: centre {}, want 414", block_centre(&l));
// Content wider than the box overflows symmetrically past both edges (no wrap).
let body_wide = TextBody {
body: BodyProps { anchor_ctr: Some(true), wrap: Some(false), ..Default::default() },
paragraphs: vec![para(Some(Align::Right), text)],
..Default::default()
};
let (p, mut sh) = setup("", 300.0, 104.0);
sh.text = Some(body_wide);
let l = lay(&p, &sh, 300.0, 104.0);
assert!((block_centre(&l) - 150.0).abs() <= 2.0, "overflowing: centre {}, want 150", block_centre(&l));
// Control: without anchorCtr a right-aligned line still ends at the box's right edge.
let body_control = TextBody { body: BodyProps::default(), paragraphs: vec![para(Some(Align::Right), text)], ..Default::default() };
let (p, mut sh) = setup(text, 828.0, 104.0);
sh.text = Some(body_control);
let l = lay(&p, &sh, 828.0, 104.0);
assert!(
l.lines.iter().all(|li| li.extent.1 > 810.0),
"control: right-aligned at the box edge, got {:?}",
l.lines.iter().map(|li| li.extent).collect::<Vec<_>>()
);
}
#[test]
fn empty_bullets_reserve_the_typed_indent_without_becoming_content() {
for rtl in [false, true] {
for level in [0, 1, 2] {
let (p, mut sh) = setup("First\n", 600.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.level = level;
para.props.rtl = Some(rtl);
para.props.align = Some(if rtl { Align::Right } else { Align::Left });
}
let empty = lay(&p, &sh, 600.0, 300.0);
assert_eq!(empty.empty_bullets.len(), 1);
assert_eq!(empty.empty_bullets[0].para, 1);
assert!(empty.runs.iter().all(|r| r.para == 0), "empty bullet must not be exported");
let caret = empty.caret(Pos { para: 1, ch: 0 }).unwrap();
let bx = empty.empty_bullets[0].glyphs[0].1;
assert!(if rtl { bx >= caret.0 } else { bx < caret.0 });
deckcraft_model::edit::insert(sh.text.as_mut().unwrap(), (1, 0), if rtl { "אבג" } else { "Next" }, None);
let typed = lay(&p, &sh, 600.0, 300.0);
assert!(typed.empty_bullets.is_empty());
let after = typed.caret(Pos { para: 1, ch: 0 }).unwrap();
assert!(
(caret.0 - after.0).abs() < 1e-6 && (caret.1 - after.1).abs() < 1e-6 && (caret.2 - after.2).abs() < 1e-6,
"typing must not move the line's start: {caret:?} vs {after:?}"
);
}
}
}
#[test]
fn empty_numbered_paragraph_previews_the_next_number_without_consuming_it() {
let (p, mut sh) = setup("One\n\nTwo", 600.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.props.bullet = Some(Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 4 });
}
let l = lay(&p, &sh, 600.0, 300.0);
let preview = &l.empty_bullets[0];
let next = l.runs.iter().find(|r| r.para == 2 && r.cells.is_empty()).unwrap();
let ids = |r: &GlyphRun| r.glyphs.iter().map(|g| g.0).collect::<Vec<_>>();
assert_eq!(preview.para, 1);
assert_eq!(ids(preview), ids(next), "both should use 5.");
let expected = measure_str(&preview.face, preview.size, "5.").1;
assert_eq!(ids(preview), expected.iter().map(|g| g.0).collect::<Vec<_>>());
}
#[test]
fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() {
let (p, mut sh) = setup("", 600.0, 300.0);
sh.text.as_mut().unwrap().paragraphs[0].props.bullet = Some(Bullet::None);
assert!(lay(&p, &sh, 600.0, 300.0).empty_bullets.is_empty());
sh.text = Some(TextBody::from_text("First"));
deckcraft_model::edit::insert(sh.text.as_mut().unwrap(), (0, 5), "\u{b}", None);
let l = lay(&p, &sh, 600.0, 300.0);
assert_eq!(l.lines.len(), 2);
assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item");
}
+62 -8
View File
@@ -181,20 +181,18 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
// One texture pixel in screen points, and the canvas in pixels from the slide's corner.
let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32);
let visible = visible_region(avail, slide_rect, pixel);
let typing_into = st.and_then(|d| d.selection.text.as_ref()).filter(|t| !t.notes).map(|t| t.shape);
let tex = match st.map(|d| (d.selection.target, d)) {
Some((Target::Slides, d)) => {
let idx = d.selection.slide;
let doc = d.doc.clone();
let frame = anim.as_ref().map(|(tl, t)| (tl, *t));
// A placeholder's prompt makes way for the caret once it is being typed into.
let typing_into = d.selection.text.as_ref().filter(|t| !t.notes && t.cell.is_none()).map(|t| t.shape);
let no_prompt = typing_into.filter(|id| d.shape(*id).and_then(|sh| sh.text.as_ref()).is_none_or(|body| body.is_empty()));
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, &times, no_prompt, frame)
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, &times, typing_into, frame)
}
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible),
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible),
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible, typing_into),
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible, typing_into),
Some((t @ (Target::Handout | Target::NotesMaster), d)) => {
deckcraft_engine::master_page(&d.doc, t).and_then(|p| master_tex(ui.ctx(), &p, 0, None, px, visible))
deckcraft_engine::master_page(&d.doc, t).and_then(|p| master_tex(ui.ctx(), &p, 0, None, px, visible, typing_into))
}
None => None,
};
@@ -418,6 +416,7 @@ fn master_tex(
layout: Option<usize>,
px: (u32, u32),
visible: deckcraft_render::Region,
editing_text: Option<deckcraft_model::ShapeId>,
) -> Option<(egui::TextureHandle, deckcraft_render::Region)> {
let scale = px.0 as f64 / doc.slide_size.width.max(1.0);
let region = crate::textures::fit_texture(
@@ -429,7 +428,7 @@ fn master_tex(
doc,
master,
layout,
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
&deckcraft_render::RenderOpts { scale, edit: true, editing_text, offset: region.offset(), size: Some(region.size()), ..Default::default() },
);
Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region))
}
@@ -1476,6 +1475,61 @@ mod tests {
}
}
/// The first empty placeholder and the paragraph after Enter keep a list marker and indent.
#[test]
fn empty_bullet_preview_follows_click_type_enter_and_toggle() {
let mut h = harness();
h.state_mut().run("slide.new", json!({"layout": "titleAndContent"})).unwrap();
h.run_steps(3);
let st = h.state().session.active().unwrap();
let sh = st
.shapes()
.iter()
.find(|s| s.ph_type() == Some(deckcraft_model::PhType::Body) || s.ph_type() == Some(deckcraft_model::PhType::Obj))
.unwrap();
let id = sh.id;
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
let p = screen(h.state(), x.x + 40.0, x.y + 20.0);
click(&mut h, p);
h.run_steps(3);
let caret = |h: &Harness<'_, SlideApp>| {
let st = h.state().session.active().unwrap();
let ts = st.selection.text.as_ref().expect("click starts typing");
assert_eq!(ts.shape, id);
deckcraft_engine::cmd::text::caret_line(st, ts).unwrap().0
};
let first = caret(&h);
h.event(Event::Text("First point".into()));
h.run_steps(3);
h.key_press(egui::Key::Enter);
h.run_steps(3);
let second = caret(&h);
assert!((first.x - second.x).abs() < 0.01, "empty lines retain the bullet indent");
assert!(second.y > first.y);
let st = h.state().session.active().unwrap();
let l = deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap();
assert_eq!(l.empty_bullets.len(), 1);
assert_eq!(l.empty_bullets[0].para, 1);
let renders = h.state().textures.canvas_renders;
h.state_mut().run("text.exit", json!({})).unwrap();
h.run_steps(3);
assert!(h.state().textures.canvas_renders > renders, "leaving text editing refreshes the marker texture");
h.state_mut().run("text.edit", json!({"id": id, "at": [1, 0]})).unwrap();
h.run_steps(3);
h.event(Event::Text("Second point".into()));
h.run_steps(3);
let st = h.state().session.active().unwrap();
let ts = st.selection.text.as_ref().unwrap();
assert_eq!(deckcraft_engine::cmd::text::body_of(st, ts).unwrap().text(), "First point\nSecond point");
assert!(deckcraft_engine::cmd::text::layout_for(st, ts).unwrap().empty_bullets.is_empty());
h.key_press(egui::Key::Enter);
h.run_steps(3);
h.state_mut().run("format.bullets", json!({})).unwrap();
h.run_steps(3);
let st = h.state().session.active().unwrap();
assert!(deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap().empty_bullets.is_empty());
}
/// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked.
#[test]
fn double_click_in_a_table_cell_shows_the_caret_there() {
+44 -1
View File
@@ -28,12 +28,34 @@ pub struct ControlRequest {
pub method: String,
pub params: Value,
pub reply: Sender<ControlResponse>,
/// Who has the request: [`PENDING`], [`TAKEN`] (the app runs it) or [`ABANDONED`] (the
/// sender gave up on it, so the app must not run it late: issue #719).
pub state: std::sync::Arc<std::sync::atomic::AtomicU8>,
}
/// Queued, not yet run.
pub const PENDING: u8 = 0;
/// The app has started it: it will answer.
pub const TAKEN: u8 = 1;
/// Its sender gave up waiting (the reply timeout expired): it is dropped unrun.
pub const ABANDONED: u8 = 2;
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx }, rx)
(Self { method: method.into(), params, reply: tx, state: Default::default() }, rx)
}
/// Claim the request for running; false when its sender already gave up on it.
pub fn take(&self) -> bool {
use std::sync::atomic::Ordering;
self.state.compare_exchange(PENDING, TAKEN, Ordering::SeqCst, Ordering::SeqCst).is_ok() || self.state.load(Ordering::SeqCst) == TAKEN
}
/// Give up on the request; false when the app has already started it (it will answer).
pub fn abandon(state: &std::sync::atomic::AtomicU8) -> bool {
use std::sync::atomic::Ordering;
state.compare_exchange(PENDING, ABANDONED, Ordering::SeqCst, Ordering::SeqCst).is_ok() || state.load(Ordering::SeqCst) == ABANDONED
}
}
@@ -402,3 +424,24 @@ pub fn save_screenshot(app: &mut SlideApp, image: &egui::ColorImage, path: Optio
None => json!({"ok": false, "error": "no writer configured"}),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
/// `take` and `abandon` are exclusive: whoever wins, the request runs at most once (#719).
#[test]
fn take_and_abandon_are_exclusive() {
let (req, _rx) = ControlRequest::new("m", json!({}));
assert!(req.take(), "a pending request is taken");
assert!(!ControlRequest::abandon(&req.state), "a taken request is not abandoned");
assert!(req.take(), "a taken request stays taken");
let (req, _rx) = ControlRequest::new("m", json!({}));
assert!(ControlRequest::abandon(&req.state), "a pending request is abandoned");
assert!(!req.take(), "an abandoned request is never taken");
assert!(ControlRequest::abandon(&req.state), "abandoning twice stays abandoned");
}
}
+53 -5
View File
@@ -108,6 +108,9 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape));
if open && !close && !escape {
app.dialog = Some(d);
} else if !close && d.id == "preferences" {
// Esc or the window's close button: the same as Cancel.
restore_preferences(app, &d);
} else if app.dialog.is_none()
&& let Some(ret) = d.params.get("returnParams").filter(|v| v.is_object()).cloned()
{
@@ -137,6 +140,31 @@ fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) {
(ok, cancel)
}
/// What the Preferences dialog can change, as it was when the dialog opened.
fn preferences_snapshot(app: &SlideApp) -> Value {
json!({
"dark": app.ui.brightness == theme::Brightness::Dark,
"uiScale": app.ui.ui_scale,
"prefs": serde_json::to_value(&app.session.prefs).unwrap_or(Value::Null),
})
}
/// Puts back the settings the Preferences dialog opened with (Cancel, Esc, the close button).
fn restore_preferences(app: &mut SlideApp, d: &Dialog) {
let Some(before) = d.params.get("before") else { return };
if let Some(dark) = before.get("dark").and_then(Value::as_bool)
&& dark != (app.ui.brightness == theme::Brightness::Dark)
{
let _ = app.run("view.dark", json!({ "on": dark }));
}
if let Some(scale) = before.get("uiScale").and_then(Value::as_f64) {
app.ui.ui_scale = scale as f32;
}
if let Some(prefs) = before.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()) {
app.session.prefs = prefs;
}
}
fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) {
let mut v = d.get(key, default);
ui.horizontal(|ui| {
@@ -2104,14 +2132,31 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
"gridOptions" => grid_options(app, ui, d),
"preferences" => {
// The controls apply as they change, so the dialog remembers what it opened with and
// Cancel (or Esc, or the close button: see `show`) puts that back.
if d.params.get("before").is_none() {
d.params = json!({ "before": preferences_snapshot(app) });
}
ui.label(egui::RichText::new("General").font(theme::bold(13.0)));
let mut dark = app.ui.brightness == theme::Brightness::Dark;
if ui.checkbox(&mut dark, "Dark appearance").changed() {
let _ = app.run("view.dark", json!({"on": dark}));
}
let mut scale = app.ui.ui_scale;
if ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text("Interface size")).changed() {
app.ui.ui_scale = scale;
// While the pointer holds the slider its value only waits in the dialog: applying it
// would rescale the slider under the pointer and run it to an end (#767). It applies
// on release, and at once from the keyboard.
let pending = d.fields.get("uiScale").and_then(|v| v.parse::<f32>().ok());
let mut scale = pending.unwrap_or(app.ui.ui_scale);
let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text("Interface size"));
if r.is_pointer_button_down_on() {
if r.changed() || pending.is_some() {
d.fields.insert("uiScale".into(), scale.to_string());
}
} else {
if r.changed() || pending.is_some() {
app.ui.ui_scale = scale;
}
d.fields.remove("uiScale");
}
ui.label(egui::RichText::new("Editing").font(theme::bold(13.0)));
ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides");
@@ -2130,8 +2175,11 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.label("Your name (comments):");
ui.text_edit_singleline(&mut app.session.prefs.author);
});
let (ok, _) = buttons(ui, "Done");
ok
let (ok, cancel) = buttons(ui, "Done");
if cancel {
restore_preferences(app, d);
}
ok || cancel
}
"photoAlbum" => photo_album_dialog(app, ui, d),
"themeColors" => theme_colors_dialog(app, ui, d),
+103
View File
@@ -1879,6 +1879,10 @@ impl SlideApp {
fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
// A request whose sender gave up waiting (reply timeout) must not run late (#719).
if !req.take() {
continue;
}
let reply = req.reply.clone();
match control::handle(self, ctx, &req) {
control::Outcome::Done(v) => {
@@ -2573,6 +2577,71 @@ mod tests {
assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9);
}
/// Preferences (#752): Cancel, Esc and the window's close button put back what the dialog
/// changed while it was open; Done keeps it.
#[test]
fn preferences_cancel_restores_the_settings_done_keeps_them() {
use egui_kittest::kittest::Queryable;
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(Session::with_new(), Services::default()),
);
harness.run_steps(2);
let before = harness.state().session.prefs.clone();
let dark_before = harness.state().ui.brightness;
let change = |harness: &mut egui_kittest::Harness<'static, SlideApp>| {
harness.state_mut().run("app.preferences", json!({})).unwrap();
harness.run_steps(2);
for label in ["Dark appearance", "Show smart guides", "Snap objects to grid"] {
harness
.query_all_by_role_and_label(egui::accesskit::Role::CheckBox, label)
.next()
.unwrap_or_else(|| panic!("no {label} checkbox"))
.click_accesskit();
harness.run_steps(2);
}
assert_ne!(harness.state().ui.brightness, dark_before, "the dialog previews the appearance");
assert_ne!(harness.state().session.prefs.smart_guides, before.smart_guides);
};
let unchanged = |harness: &egui_kittest::Harness<'static, SlideApp>, how: &str| {
let s = harness.state();
assert!(s.dialog.is_none(), "{how} closes Preferences");
assert_eq!(s.ui.brightness, dark_before, "{how} restores the appearance");
assert_eq!(s.session.prefs.smart_guides, before.smart_guides, "{how} restores smart guides");
assert_eq!(s.session.prefs.snap_to_grid, before.snap_to_grid, "{how} restores snap to grid");
};
change(&mut harness);
harness
.query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel")
.next()
.unwrap_or_else(|| panic!("no Cancel button"))
.click_accesskit();
harness.run_steps(2);
unchanged(&harness, "Cancel");
change(&mut harness);
harness.key_press(egui::Key::Escape);
harness.run_steps(2);
unchanged(&harness, "Esc");
change(&mut harness);
harness
.query_all_by_role_and_label(egui::accesskit::Role::Button, "Done")
.next()
.unwrap_or_else(|| panic!("no Done button"))
.click_accesskit();
harness.run_steps(2);
let s = harness.state();
assert!(s.dialog.is_none(), "Done closes Preferences");
assert_ne!(s.ui.brightness, dark_before, "Done keeps the appearance");
assert_ne!(s.session.prefs.smart_guides, before.smart_guides, "Done keeps smart guides");
assert_ne!(s.session.prefs.snap_to_grid, before.snap_to_grid, "Done keeps snap to grid");
}
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
@@ -3253,4 +3322,38 @@ mod tests {
flick(&mut harness, egui::vec2(-24.0, 0.0));
assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0));
}
/// Preferences › Interface size (#767): dragging the slider leaves the interface size alone
/// until the pointer lets go, so the slider doesn't grow under the pointer mid-drag.
#[test]
fn interface_size_applies_when_the_slider_is_let_go() {
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(Session::with_new(), Services::default()),
);
harness.run_steps(2);
harness.state_mut().run("app.preferences", json!({})).unwrap();
harness.run_steps(4);
let before = harness.state().ui.ui_scale;
let rect =
harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Interface size").next().expect("the Interface size slider").rect();
let (from, to) = (rect.left_center() + egui::vec2(4.0, 0.0), rect.right_center() - egui::vec2(4.0, 0.0));
harness.hover_at(from);
harness.run_steps(1);
harness.drag_at(from);
harness.run_steps(1);
for k in 1..=8 {
harness.hover_at(from.lerp(to, k as f32 / 8.0));
harness.run_steps(1);
}
assert_eq!(harness.state().ui.ui_scale, before, "nothing rescales while the slider is held");
harness.drop_at(to);
harness.run_steps(2);
let after = harness.state().ui.ui_scale;
assert!(after > before + 0.3, "letting go applies the size dragged to: {before} -> {after}");
assert!(harness.state().dialog.is_some(), "Preferences stays open");
}
}
+4 -1
View File
@@ -233,7 +233,9 @@ fn notes_menu_language_dialog_and_repeated_words() {
if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_NOTES"), h.render()) {
let _ = img.save(path);
}
// Right-click the notes' first word.
// Right-click the notes' first word, once the (background) notes check is in.
assert_eq!(h.state_mut().squiggles.settle_notes("Remmeber the the plan", "xx-NM"), vec![(0, 8), (13, 16)]);
h.run_steps(2);
let notes = h.query_all_by_role(Role::MultilineTextInput).next().map(|n| n.rect()).unwrap();
right_click(&mut h, egui::pos2(notes.left() + 14.0, notes.top() + 8.0));
if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_NOTES_MENU"), h.render()) {
@@ -245,6 +247,7 @@ fn notes_menu_language_dialog_and_repeated_words() {
let notes_text = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().doc.slides[0].notes_text();
assert_eq!(notes_text(&h), "Remember the the plan");
// The repeated "the": its squiggle starts after "Remember the ".
h.state_mut().squiggles.settle_notes("Remember the the plan", "xx-NM");
h.run_steps(4);
let x = h.query_all_by_role(Role::MultilineTextInput).next().map(|n| n.rect()).unwrap();
let mut found = false;
+6 -7
View File
@@ -21,7 +21,7 @@ struct Key {
extra: u64,
/// The pasteboard region the slide's drawing calls for (canvas only).
want: Region,
skip_text: Option<ShapeId>,
editing_text: Option<ShapeId>,
}
struct Entry {
@@ -248,8 +248,7 @@ impl Textures {
/// The canvas texture for slide `index`, the slide being `size` pixels, and the region it
/// covers: the slide, grown over the pasteboard to show what reaches off the slide within
/// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the
/// GIF frames. `skip_text` leaves out one shape's text (the prompt of a placeholder being typed
/// into).
/// GIF frames. `editing_text` replaces its placeholder prompt with gray empty list markers.
#[allow(clippy::too_many_arguments)]
pub fn slide(
&mut self,
@@ -262,7 +261,7 @@ impl Textures {
extra: u64,
grayscale: bool,
gif_times: &[(ShapeId, f64)],
skip_text: Option<ShapeId>,
editing_text: Option<ShapeId>,
anim: Option<(&deckcraft_anim::Timeline, f64)>,
) -> Option<(TextureHandle, Region)> {
let slide = p.slides.get(index)?.clone();
@@ -290,7 +289,7 @@ impl Textures {
edit,
extra: extra ^ grayscale as u64 ^ anim_key(anim),
want,
skip_text,
editing_text,
};
if let Some(e) = &self.canvas
&& e.key == key
@@ -309,7 +308,7 @@ impl Textures {
offset: region.offset(),
size: Some(region.size()),
state,
skip_text,
editing_text,
..Default::default()
};
deckcraft_render::render_slide_drawn(p, index, &opts)
@@ -367,7 +366,7 @@ impl Textures {
edit: false,
extra: 0,
want: Region::slide(width_px, h),
skip_text: None,
editing_text: None,
};
self.clock += 1;
let clock = self.clock;
+20 -2
View File
@@ -19,6 +19,10 @@ use deckcraft_fonts::{FontDb, FontFace};
/// 55 MB, the only Hangul face) and `PingFang.ttc` (about 78 MB, Simplified Chinese).
const MAX_FACE_BYTES: usize = 128 << 20;
fn accepts_face(byte_len: usize, color: bool) -> bool {
!color && byte_len <= MAX_FACE_BYTES
}
/// Faces borrowed for the interface, and the characters already looked at.
#[derive(Default)]
pub struct UiFonts {
@@ -53,7 +57,7 @@ pub fn faces_for_text(
continue;
}
let Some(f) = lookup(c) else { continue };
if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) {
if !accepts_face(f.data().len(), f.is_color()) || have.iter().chain(&out).any(|h| h.id() == f.id()) {
continue;
}
out.push(f);
@@ -69,7 +73,13 @@ impl UiFonts {
}
let font = egui::FontId::proportional(13.0);
let new = ctx.fonts_mut(|f| {
faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX))
faces_for_text(
text,
&self.faces,
&mut self.seen,
|c| f.has_glyph(&font, c),
|c| FontDb::global().fallback_for_filtered(c, u32::MAX, |face| accepts_face(face.data().len(), face.is_color())),
)
});
if !new.is_empty() {
log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
@@ -168,6 +178,14 @@ mod tests {
FontDb::global().face(family, "Regular")
}
#[test]
fn interface_face_policy_rejects_colour_and_oversize_faces() {
assert!(accepts_face(MAX_FACE_BYTES, false), "the documented size boundary remains accepted");
assert!(!accepts_face(MAX_FACE_BYTES + 1, false), "an oversized outline face is still rejected");
assert!(!accepts_face(1, true), "even a small colour face is still rejected");
assert!(!accepts_face(MAX_FACE_BYTES + 1, true));
}
/// One face for a whole string, looked up once per character, never for characters the
/// interface already draws, and never twice.
#[test]
+2 -1
View File
@@ -67,7 +67,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
| Effect Options (direction, sequence by paragraph) | yes | yes |
| Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; repeat and bounce end round-trip; rewind lost on PPTX save (#77) |
| By-paragraph text builds, sub-bullets follow parents | yes | yes |
| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step |
| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step. Separate click steps, mixed starts and paragraph builds survive PPTX save (`trigger_steps.rs`) |
| Triggers on a media bookmark | yes | no |
| Animation Painter | yes | yes (#54) |
| Animation Pane: list, reorder, remove | yes | yes |
@@ -94,6 +94,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores |
| 2026-10-14 | trivial | Community PR wave folded: the Animation Pane timeline row says yes (it landed), preview split into canvas (yes) and from the selected effect (no); estimates drop the timeline and transition sounds (~15–27 h) |
| 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) |
| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide's animations on the editor canvas; preview from the selected effect is still open |
+7 -1
View File
@@ -19,7 +19,7 @@ nothing below L6 uses egui, eframe, winit or rfd.
| L1 | `model`, `fonts`; `h264`, `hevc`, `vp9`, `av1`, `isobmff` | Presentation, masters, layouts, slides, shapes, text, themes, tables, charts, transitions, animations, placeholder inheritance (`resolve`); font database (craft-fonts + system) with metric-compatible substitutes for Office fonts (Carlito for Calibri, Caladea for Cambria, Liberation for Arial/Times); video decoders and the MP4/MOV demuxer |
| L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock |
| L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 |
| L4 | `pdf` | PDF export through krilla |
| L4 | `pdf` | App-owned export layout and rendering through the shared `craft-pdf` writer |
| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (320 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) |
| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 41 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app |
| apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering |
@@ -94,3 +94,9 @@ macOS, Windows, Linux, FreeBSD and web artifacts.
| 2026-10-14 | minor | Font dialog; command registry 227; 28 dialogs |
| 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor |
| 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 |
The PDF writer dependency enables only `write`. Krilla handles serialization and simple-text
shaping for notes and footers; slide layout, rasterization, font selection, text mapping,
links and outlines remain app-owned. PDF embedding and all Hayro production dependencies
are disabled. Native builds enable parallel compression on the same pinned Krilla backend;
WASM keeps it disabled. Generated-PDF assertions use a std-only parser as a development dependency.
+13 -1
View File
@@ -3,10 +3,22 @@
Start the app with `--control <port>`. The server listens on `127.0.0.1` only and speaks JSON
lines: one request object per line, one reply per line.
A line that is not a JSON object with a string `method` (an HTTP request, text that isn't JSON,
bytes that aren't UTF-8) gets one error reply and the server closes the connection; nothing sent
after it runs. Blank lines are skipped.
The connected MCP client requires the reply's numeric `id` to match its request, `ok` to be a
boolean, and exactly one of `result` (success) or a string `error` (failure). It caps each reply at
16 MiB including its terminating newline, accepts CRLF, and rejects EOF inside a frame. Invalid
replies invalidate the connection and leave the command's outcome unknown; the client does not
replay it. Unsolicited bytes already buffered between calls force a reconnect before sending.
The server bounds what a client can hold: 16 connections at once (more get `{"ok":false,"error":"busy: …"}`
and are closed), 16 MiB per request line (longer closes the connection), 64 queued requests (more are
answered `busy`), 10 minutes idle and 30 seconds per blocked reply write before the socket is closed, and
60 seconds waiting for the UI (`"error":"timeout"`).
60 seconds waiting for the UI (`"error":"timeout"`). A request still queued when that wait expires is
abandoned: the UI skips it, so a retry cannot run it twice. If the UI had already started it, the reply
says the outcome is unknown instead of `timeout`.
```json
{"id": 1, "method": "engine.execute", "params": {"command": "shape.insert", "params": {"preset": "roundRect", "rect": [100, 100, 240, 120], "text": "Hello"}}}
+1
View File
@@ -103,6 +103,7 @@ the round trip even though DeckCraft doesn't model it.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen |
| 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) |
| 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box |
| 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain |
+6 -5
View File
@@ -212,11 +212,10 @@ of the effect gallery. Detail in [animation-parity.md](animation-parity.md). 8
counters): a click on a shape whose action is "play sound" (`ppaction://media`, 126 and 486
shapes in two decks) does nothing; triggers that start on `onStopAudio` or `begin` conditions are
not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count) and do
not follow a shape that a motion path has moved; the PPTX writer turns every "on click" after the
first effect of a trigger into "after previous", so a trigger with several click steps saved from
DeckCraft plays all of them at once.
- **Impact:** games built around sound effects on buttons, or around a button that counts up with
repeated clicks, lose that part when they are saved from DeckCraft or played in it.
not follow a shape that a motion path has moved. Trigger sequences now keep their separate
click steps on PPTX save, including paragraph builds (`crates/pptx/tests/trigger_steps.rs`).
- **Impact:** games built around sound effects on buttons, audio-triggered effects or moving
clickable shapes do not play as authored.
- **Estimate:** 4–8 h.
### Notes and Handout masters
@@ -355,6 +354,8 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | trivial | PPTX trigger sequences keep later On Click steps instead of converting them to After Previous; mixed starts, independent buttons and paragraph builds have synthetic round-trip regressions |
| 2026-10-14 | trivial | Fixed #718: remote MCP no longer accepts unrelated or buffered stale success; loopback regressions cover invalid envelopes, incomplete and oversized frames |
| 2026-10-14 | minor | Community PR wave folded: one entry each for the soak tests (engine and UI), the PPTX corpus (seven owned corpus files), edit points / text warp / 3-D, proofing (Hunspell spelling, thesaurus, AutoCorrect), the smaller missing rows, dialogs (41 vs ~60; Print and Insert Object left) and pen/trackpad input; ranked rows 3, 4 and 10 de-duplicated (comment threads #70 and the pane timeline are done); the fixed command-palette entry (#79) removed |
| 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain |
| 2026-10-13 | minor | Embedded fonts are read, written and drawn (TrueType, OpenType, Embedded OpenType). The gap is now MicroType Express subsets, which are kept but not drawn |
+12
View File
@@ -5,6 +5,18 @@ headless engine (no window); with `--connect PORT` it drives a running DeckCraft
`--control PORT`, so an agent and a person can work on the same deck. `--sample` opens the sample
deck.
Connected mode accepts only newline-terminated replies of at most 16 MiB (including the newline),
with the current request's numeric `id`, a boolean `ok`, and either `result` on success or a string
`error` on failure. CRLF is accepted. Invalid replies close the connection and report that the
command may or may not have run; they never cause a replay. Check the document before retrying a
mutation. Before sending another request, unsolicited bytes in the reader's buffer or on the
socket cause a fresh connection to be opened. Large image replies must fit this budget; render
to a file or use a smaller scale when necessary.
Regression coverage for #718 lives in `crates/mcp/src/backend.rs`: synthetic loopback peers test
reply correlation, envelopes, framing, the size cap and buffered unsolicited bytes, alongside the
lost-reply and reconnect tests.
```json
{
"mcpServers": {
+4 -4
View File
@@ -1,6 +1,6 @@
# PowerPoint feature checklist
> **Generated:** 2026-10-14 (summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
The summary between `<!-- SUMMARY -->
**Weighted breadth parity: 84%** over 191 features.
@@ -145,7 +145,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Text | Change case, grow/shrink font, clear formatting | P1 | D |
| Text | Text outline and text effects | P2 | P |
| Text | Paragraph alignment, indents, spacing, line spacing | P0 | D |
| Text | Bullets and numbering | P0 | D |
| Text | Bullets and numbering (including gray empty-line markers while editing) | P0 | D |
| Text | Picture bullets, custom bullet symbols | P2 | P |
| Text | List levels (Tab / Shift+Tab) | P0 | D |
| Text | Tab stops | P2 | D |
@@ -206,7 +206,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Animations | Effect options, start (on click/with/after), duration, delay | P0 | D |
| Animations | Animation pane (reorder, timing) | P0 | P |
| Animations | By-paragraph text builds | P1 | D |
| Animations | Triggers | P2 | D |
| Animations | Triggers (separate click steps and paragraph builds round-trip; `crates/pptx/tests/trigger_steps.rs`) | P2 | D |
| Animations | Animation Painter | P2 | D |
| Animations | Preview in editor | P1 | D |
| Slide show | Full-screen show from start / current | P0 | D |
@@ -228,7 +228,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Undo & clipboard | Unlimited undo/redo | P0 | D |
| Undo & clipboard | Cut, copy, paste shapes and slides | P0 | D |
| Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | D |
| Automation (✱) | Every command scriptable (CLI, control channel, MCP) | P0 | D |
| Automation (✱) | Every command scriptable (CLI, control channel, MCP; remote reply IDs, envelopes and framing validated, #718) | P0 | D |
| Automation (✱) | Headless rendering and conversion | P0 | D |
| Platforms (✱) | macOS, Windows, Linux builds | P0 | P |
| Platforms (✱) | Web (WASM) build | P1 | D |
+5
View File
@@ -115,6 +115,8 @@ plan above.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | trivial | PPTX trigger click steps survive save, including by-paragraph builds. Interactive-deck gaps remain media actions, audio triggers and click-box geometry; milestone scope and feature scores unchanged |
| 2026-10-14 | trivial | M13 reliability: remote MCP validates IDs and envelopes, caps replies and rejects incomplete frames; #718 |
| 2026-10-14 | minor | Community PR wave folded: one Review gate row, one Current focus item 2, one beta row each for items 9 and 10 (timeline and timer controls are in), one after-beta paragraph, one M9 and M10 row, one corpus row in the 2026-10-07 history |
| 2026-10-13 | trivial | Beta item 3: embedded fonts are in, except MicroType Express subsets |
| 2026-10-13 | minor | M9: the WordArt Transform gallery applies a text warp; adjust handles stay open |
@@ -138,3 +140,6 @@ plan above.
| 2026-10-14 | trivial | After beta: pen pressure scales ink width; tilt and pinch zoom remain |
| 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha |
| 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history |
PDF writer consolidation preserves the current raster-artwork export and its selectable
text layer, notes and handouts. It does not advance vector-PDF or printing parity.
+8
View File
@@ -7,6 +7,13 @@ direct manipulation, keyboard and precision. Observations of PowerPoint are kept
`plan/powerpoint/01-observed-ui.md` and `menu-tree.txt` (gitignored, clean-room rules in
AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to parity.
## Empty list paragraphs
While editing slide text, empty bulleted and numbered paragraphs show gray markers at the list's
indent, including the first click into a content placeholder and the paragraph after Enter.
Typing gives the marker its normal text color. These temporary markers are hidden when editing
ends and are omitted from thumbnails, slide shows and exports.
## Shell
| Element | PowerPoint for Mac | DeckCraft | Gap |
@@ -158,6 +165,7 @@ consistency is open (#73, #56).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
| 2026-10-14 | minor | Community PR wave folded: one row each for Format Shape, Dialogs (41, listed from `dialogs.rs`), Animation Pane (timeline is in), window, Quick Access Toolbar, Format Background, rulers, image paste, context menus, exact size and edit points; estimates re-totalled (~26–46 h) |
| 2026-10-13 | trivial | Eyedropper in the colour menus (#367) |
| 2026-10-12 | minor | Title bar: an Export button next to Save opens the Export dialog, a quick-access shortcut beside File › Export |
+116
View File
@@ -0,0 +1,116 @@
CC0 1.0 Universal
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator and
subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for the
purpose of contributing to a commons of creative, cultural and scientific
works ("Commons") that the public can reliably and without fear of later
claims of infringement build upon, modify, incorporate in other works, reuse
and redistribute as freely as possible in any form whatsoever and for any
purposes, including without limitation commercial purposes. These owners may
contribute to the Commons to promote the ideal of a free culture and the
further production of creative, cultural and scientific works, or to gain
reputation or greater distribution for their Work in part through the use and
efforts of others.
For these and/or other purposes and motivations, and without any expectation
of additional consideration or compensation, the person associating CC0 with a
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
and publicly distribute the Work under its terms, with knowledge of his or her
Copyright and Related Rights in the Work and the meaning and intended legal
effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not limited
to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
and Related Rights and associated claims and causes of action, whether now
known or unknown (including existing as well as future claims and causes of
action), in the Work (i) in all territories worldwide, (ii) for the maximum
duration provided by applicable law or treaty (including future time
extensions), (iii) in any current or future medium and for any number of
copies, and (iv) for any purpose whatsoever, including without limitation
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
the Waiver for the benefit of each member of the public at large and to the
detriment of Affirmer's heirs and successors, fully intending that such Waiver
shall not be subject to revocation, rescission, cancellation, termination, or
any other legal or equitable action to disrupt the quiet enjoyment of the Work
by the public as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason be
judged legally invalid or ineffective under applicable law, then the Waiver
shall be preserved to the maximum extent permitted taking into account
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
is so judged Affirmer hereby grants to each affected person a royalty-free,
non transferable, non sublicensable, non exclusive, irrevocable and
unconditional license to exercise Affirmer's Copyright and Related Rights in
the Work (i) in all territories worldwide, (ii) for the maximum duration
provided by applicable law or treaty (including future time extensions), (iii)
in any current or future medium and for any number of copies, and (iv) for any
purpose whatsoever, including without limitation commercial, advertising or
promotional purposes (the "License"). The License shall be deemed effective as
of the date CC0 was applied by Affirmer to the Work. Should any part of the
License for any reason be judged legally invalid or ineffective under
applicable law, such partial invalidity or ineffectiveness shall not
invalidate the remainder of the License, and in such case Affirmer hereby
affirms that he or she will not (i) exercise any of his or her remaining
Copyright and Related Rights in the Work or (ii) assert any associated claims
and causes of action with respect to the Work, in either case contrary to
Affirmer's express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness
for a particular purpose, non infringement, or the absence of latent or
other defects, accuracy, or the present or absence of errors, whether or not
discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without limitation
any person's Copyright and Related Rights in the Work. Further, Affirmer
disclaims responsibility for obtaining any necessary consents, permissions
or other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this
CC0 or use of the Work.
For more information, please see
http://creativecommons.org/publicdomain/zero/1.0/
@@ -37,6 +37,12 @@
<keyword>slide show</keyword>
<keyword>pptx</keyword>
</keywords>
<screenshots>
<screenshot type="default">
<caption>Slide editor with thumbnails, a process diagram and notes</caption>
<image type="source" width="1600" height="1000">https://raw.githubusercontent.com/storytold/deckcraft/main/docs/images/editor.png</image>
</screenshot>
</screenshots>
<branding>
<color type="primary" scheme_preference="light">#ffc9a8</color>
<color type="primary" scheme_preference="dark">#b8471f</color>
+5 -1
View File
@@ -8,6 +8,7 @@ mod bench;
mod ico;
mod layers;
mod parity;
mod pdf_deps;
mod stats;
mod version;
@@ -19,9 +20,10 @@ usage: cargo xtask <command>
commands:
assets check that every icon/image/font/asset is attributed in ATTRIBUTION.md
pdf-deps verify pinned writer sources and the production feature boundary
layers enforce the crate dependency layering (plan/architecture.md §3)
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
ci fmt --check, pdf-deps, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
bench [--save] [DECK...]
time open, render, save and export of the sample (and DECKs) in release, and
@@ -37,6 +39,7 @@ fn main() -> ExitCode {
let result = match args.first().map(String::as_str) {
Some("assets") => assets::run(&root()),
Some("layers") => cmd_layers(),
Some("pdf-deps") => pdf_deps::run(&root()),
Some("wasm") => cmd_wasm(),
Some("ci") => cmd_ci(),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
@@ -161,6 +164,7 @@ fn cmd_ci() -> Result<(), String> {
run(c, "cargo fmt --all -- --check")
}),
),
("pdf-deps", Box::new(|| pdf_deps::run(&root()))),
(
"clippy",
Box::new(|| {
+227
View File
@@ -0,0 +1,227 @@
//! Check the immutable writer sources and keep PDF reading out of production exports.
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use std::process::Command;
use serde_json::Value;
const SHARED_REV: &str = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8";
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
pub fn run(root: &Path) -> Result<(), String> {
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
.current_dir(root)
.args(["metadata", "--locked", "--format-version", "1"])
.output()
.map_err(|e| format!("cargo metadata: {e}"))?;
if !output.status.success() {
return Err(format!("cargo metadata: {}", String::from_utf8_lossy(&output.stderr)));
}
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?;
validate(&metadata)?;
// Metadata merges target features even with --filter-platform. Check Cargo's
// selected feature graph separately so native compression never enables threads on WASM.
let output =
Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())).arg("-vV").output().map_err(|e| format!("rustc host: {e}"))?;
if !output.status.success() {
return Err(format!("rustc host: {}", String::from_utf8_lossy(&output.stderr)));
}
let version = String::from_utf8(output.stdout).map_err(|e| format!("rustc host: {e}"))?;
let host = version.lines().find_map(|line| line.strip_prefix("host: ")).ok_or("rustc did not report its host target")?;
for (target, parallel) in [(host, true), ("wasm32-unknown-unknown", false)] {
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
.current_dir(root)
.args([
"tree",
"--locked",
"--target",
target,
"--edges",
"normal,build",
"--package",
"deckcraft-pdf",
"--depth",
"2",
"--no-dedupe",
"--prefix",
"none",
"--format",
"{p}|{f}",
])
.output()
.map_err(|e| format!("cargo tree ({target}): {e}"))?;
if !output.status.success() {
return Err(format!("cargo tree ({target}): {}", String::from_utf8_lossy(&output.stderr)));
}
validate_target_features(&String::from_utf8_lossy(&output.stdout), parallel).map_err(|e| format!("{target}: {e}"))?;
}
println!("PDF writer: pinned craft-pdf/Krilla; native-only parallel compression; parser assertions are development-only");
Ok(())
}
fn validate(metadata: &Value) -> Result<(), String> {
let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?;
let nodes = metadata.get("resolve").and_then(|r| r.get("nodes")).and_then(Value::as_array).ok_or("cargo metadata has no resolved nodes")?;
let one_package = |name: &str| -> Result<&Value, String> {
let matches: Vec<_> = packages.iter().filter(|p| p.get("name").and_then(Value::as_str) == Some(name)).collect();
if matches.len() != 1 {
return Err(format!("{name}: expected one resolved package, found {}", matches.len()));
}
matches.first().copied().ok_or_else(|| format!("missing {name}"))
};
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
for (name, version, expected_source, expected_features) in [
("craft-pdf", "0.1.0", source.as_str(), vec!["write"]),
("krilla", "0.8.2", source.as_str(), vec!["default", "raster-images", "rayon", "simple-text"]),
("hayro-syntax", "0.7.2", REGISTRY, vec!["std"]),
] {
let package = one_package(name)?;
if package.get("version").and_then(Value::as_str) != Some(version) || package.get("source").and_then(Value::as_str) != Some(expected_source) {
return Err(format!("{name}: expected {version} from {expected_source}; got {package}"));
}
let id = package.get("id").and_then(Value::as_str).ok_or_else(|| format!("{name}: missing package id"))?;
let node = nodes.iter().find(|n| n.get("id").and_then(Value::as_str) == Some(id)).ok_or_else(|| format!("{name}: no resolved node"))?;
let features = node.get("features").and_then(Value::as_array).ok_or_else(|| format!("{name}: no resolved features"))?;
let actual: BTreeSet<_> = features.iter().filter_map(Value::as_str).collect();
let expected: BTreeSet<_> = expected_features.into_iter().collect();
if actual != expected {
return Err(format!("{name}: expected features {expected:?}; got {features:?}"));
}
}
// Metadata includes development dependencies. Follow only normal/build edges from
// the production exporter so a parser accidentally promoted into it cannot hide there.
let by_id: HashMap<_, _> = packages.iter().filter_map(|p| p.get("id").and_then(Value::as_str).map(|id| (id, p))).collect();
let root = one_package("deckcraft-pdf")?.get("id").and_then(Value::as_str).ok_or("deckcraft-pdf has no id")?;
let mut pending = vec![root];
let mut seen = BTreeSet::new();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
let package = by_id.get(id).ok_or_else(|| format!("unresolved package {id}"))?;
let name = package.get("name").and_then(Value::as_str).ok_or("resolved package has no name")?;
if name == "hayro" || name.starts_with("hayro-") {
return Err(format!("unexpected production reader dependency {name}"));
}
let node = nodes.iter().find(|n| n.get("id").and_then(Value::as_str) == Some(id)).ok_or_else(|| format!("no resolved node for {id}"))?;
for dep in node.get("deps").and_then(Value::as_array).ok_or_else(|| format!("no dependency edges for {id}"))? {
let kinds = dep.get("dep_kinds").and_then(Value::as_array).ok_or("dependency has no kinds")?;
if kinds.is_empty() {
return Err("dependency has no kinds".into());
}
if kinds.iter().any(|k| k.get("kind").and_then(Value::as_str) != Some("dev")) {
pending.push(dep.get("pkg").and_then(Value::as_str).ok_or("dependency has no package id")?);
}
}
}
Ok(())
}
fn validate_target_features(tree: &str, parallel: bool) -> Result<(), String> {
let mut expected = BTreeSet::from(["default", "raster-images", "simple-text"]);
if parallel {
expected.insert("rayon");
}
let mut found = false;
for (package, features) in tree.lines().filter_map(|line| line.split_once('|')).filter(|(package, _)| package.starts_with("krilla v")) {
found = true;
let actual: BTreeSet<_> = features.split(',').collect();
if actual != expected {
return Err(format!("{package}: expected features {expected:?}; got {actual:?}"));
}
}
if !found {
return Err("missing Krilla in the exporter's target dependency graph".into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn metadata() -> Value {
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
json!({
"packages": [
{"id":"app", "name":"deckcraft-pdf"},
{"id":"writer", "name":"craft-pdf", "version":"0.1.0", "source":source},
{"id":"backend", "name":"krilla", "version":"0.8.2", "source":source},
{"id":"parser", "name":"hayro-syntax", "version":"0.7.2", "source":REGISTRY}
],
"resolve":{"nodes":[
{"id":"app", "deps":[{"pkg":"writer", "dep_kinds":[{"kind":null}]}, {"pkg":"parser", "dep_kinds":[{"kind":"dev"}]}]},
{"id":"writer", "features":["write"], "deps":[{"pkg":"backend", "dep_kinds":[{"kind":null}]}]},
{"id":"backend", "features":["default","raster-images","rayon","simple-text"], "deps":[]},
{"id":"parser", "features":["std"], "deps":[]}
]}
})
}
#[test]
fn parallel_compression_is_required_only_on_native_targets() {
let serial = "krilla v0.8.2 (pinned)|default,raster-images,simple-text\n";
let parallel = "krilla v0.8.2 (pinned)|default,raster-images,rayon,simple-text\n";
assert!(validate_target_features(serial, false).is_ok());
assert!(validate_target_features(parallel, true).is_ok());
assert!(validate_target_features(serial, true).is_err());
assert!(validate_target_features(parallel, false).is_err());
assert!(validate_target_features("", false).is_err());
}
#[test]
fn rejects_unpinned_or_duplicate_writer_sources() {
assert!(validate(&metadata()).is_ok());
for source in [Value::Null, json!(REGISTRY), json!("git+https://github.com/storytold/craft-libs#other")] {
let mut m = metadata();
m["packages"][1]["source"] = source;
assert!(validate(&m).is_err());
}
let mut m = metadata();
let duplicate = m["packages"][2].clone();
m["packages"].as_array_mut().unwrap().push(duplicate);
assert!(validate(&m).is_err());
}
#[test]
fn rejects_import_features_and_missing_text_shaping() {
for (node, features) in [
(1, json!([])),
(1, json!(["write", "write-pdf"])),
(2, json!(["default", "raster-images", "rayon", "simple-text", "pdf"])),
(2, json!(["raster-images"])),
(3, json!(["std", "images"])),
] {
let mut m = metadata();
m["resolve"]["nodes"][node]["features"] = features;
assert!(validate(&m).is_err());
}
}
#[test]
fn rejects_dev_parser_promoted_to_production_directly_or_transitively() {
for kind in [Value::Null, json!("build")] {
let mut m = metadata();
m["resolve"]["nodes"][0]["deps"][1]["dep_kinds"][0]["kind"] = kind;
assert!(validate(&m).is_err());
}
let mut m = metadata();
m["resolve"]["nodes"][2]["deps"] = json!([{"pkg":"parser", "dep_kinds":[{"kind":null}]}]);
assert!(validate(&m).is_err());
}
#[test]
fn rejects_incomplete_dependency_metadata() {
let mut m = metadata();
m["resolve"]["nodes"][2]["deps"] = json!([{"pkg":"missing", "dep_kinds":[{"kind":null}]}]);
assert!(validate(&m).is_err());
let mut m = metadata();
m["resolve"] = Value::Null;
assert!(validate(&m).is_err());
let mut m = metadata();
m["packages"].as_array_mut().unwrap().retain(|p| p["name"] != "hayro-syntax");
assert!(validate(&m).is_err());
}
}