Compare commits

...
Author SHA1 Message Date
aiongg 38d2ade2b7 Merge pull request #1260 from aiongg/r1-batch1
Integration batch 1: 35 contributor PRs (charts, shapes, slides, text editing, dialogs, outline files)
2026-10-24 08:38:28 -04:00
Dylan M. Taylor 7be391a1be fix(pptx): harden import resource budgets, deduplication, and lookup caching (#1283)
* fix(pptx): harden import resource budgets, deduplication, and lookup caching

- Chart sidecars (Finding 2): Enforce an import-wide retained byte budget
  and cache captured sidecars by chart part to prevent memory amplification.
- Content types cache (Finding 6): Cache parsed [Content_Types].xml defaults
  and overrides in Package.ct_cache.
- Text run splitting and font lengths (Finding 8): Bound typeface attribute
  lengths and cap paragraph runs to prevent multiplication on text splitting.
- Inner namespace decls (Finding 15): Use HashSet/HashMap indexing and cap
  inner declarations to 1,000.
- Slide and notes deduplication (Finding 16): Deduplicate slide paths and
  cache parsed notes slide bodies by target.
- Diagram media caching (Finding 22): Cache diagram media payloads in
  imp.media_by_part.
- Master slide deduplication (Finding 28): Deduplicate master paths before
  loading.
- Layout target deduplication (Finding 30): Deduplicate layout targets
  across master slides.
- Chart value axis indexing (Finding 32): Index valAx by axId using a HashMap.
- ChartEx series output budget (Finding 34): Charge series point vectors
  against an aggregate output budget and cap series count.
- ChartEx point allocation bounds (Finding 36): Allocate point vectors
  only to actual point span bounded by budget rather than declared ptCount.
- SmartArt recursion depth (Finding 38): Limit SmartArt recursion depth to 8.

* fix(pptx): address review feedback on sidecar cache, series budget, and inner decls

* fix(pptx): preserve cached sidecar bytes and non-empty category fallback
2026-10-24 06:01:30 -04:00
aiongg 528af4e503 Integration: a rejected positional revision is not turned into a deletion when a slide is inserted before it 2026-10-24 09:40:33 +00:00
aiongg 202255cbbb Integration: restore chart.rs CRLF line endings 2026-10-24 09:39:13 +00:00
aiongg 44b33a2f57 Integration: a subnormal chart data range gives a finite nice scale 2026-10-24 09:38:51 +00:00
aiongg 56b15df0ab Integration: a huge RTF left indent no longer overflows when the outline level is derived 2026-10-24 09:38:19 +00:00
aiongg a453292729 Merge main into the integration batch 2026-10-24 09:20:03 +00:00
aiongg 910b03f4a1 Integration: the GIF play/pause click works only while the pointer is awake and the button is shown 2026-10-24 09:19:46 +00:00
aiongg e679fa500a Integration: right-clicking a control handle in Edit Points keeps the picked point and its menu 2026-10-24 09:17:30 +00:00
aiongg 027075d352 Integration: a rejected positional revision stays rejected after a slide is inserted or deleted in the open deck 2026-10-24 09:16:08 +00:00
Dylan M. Taylor f86928d0d5 perf(pptx): index export lookups and deduplicate relationship parts (#1281)
- Row 31: Use HashMap for comment author lookup in author_slot
- Row 37: Index preserved parts by ID before resolving graphic frame relationships
- Row 10: Index kept chart parts by normalized path and avoid duplicate payload scans
- Row 39: Update existing relationship parts instead of appending duplicates in PackageOut
2026-10-24 05:16:07 -04:00
aiongg 2bbfe46d3e Integration: slides with two visible pictures get no layout ideas 2026-10-24 09:14:11 +00:00
aiongg 9dcb7bab03 Integration: the automatic-date test reads the clock before and after the command so a tick of the second cannot fail it 2026-10-24 09:13:29 +00:00
aiongg 75bec53e08 Integration: RTF import drops NUL units and masks \u to 16 bits so the surrogate marker cannot be forged 2026-10-24 09:11:34 +00:00
Dylan M. Taylor c55a9f771e security(metafile): harden EMF+ parsing, clipping complexity, and memory accounting (#1280)
* fix(metafile): harden EMF+ parsing, clipping complexity, and memory accounting

- Row 72: Use checked tail slicing in comment header offset calculations
- Row 78: Break FillRects and DrawRects loops early when canvas is full
- Row 75: Pre-check dimensions in decode_compressed before RGBA pixel allocation
- Row 85: Account for EMF+ object table image/path memory against MAX_BYTES
- Row 42: Replace O(N^2) prefix copying in wrap_line with linear measurement
- Row 43 & 54: Share immutable clip regions Arc across placed items and charge canvas regions
- Row 48: Bound text transformation scale and glyph curve geometry in Picture::place and draw_text
- Row 52 & 68: Impose work budget bounds on Sutherland-Hodgman polygon clipping and bitmap masking

* fix(metafile): collapse nested if statement per clippy
2026-10-24 05:06:04 -04:00
Dylan M. Taylor c955407373 security(engine): check regular file and size bound on read, preserve permissions and symlinks on save (#1265)
* security(engine): check regular file and size bound on read, preserve permissions and symlinks on save

* style: format recovery.rs write_atomic
2026-10-24 04:45:48 -04:00
Dylan M. TaylorandClaude Sonnet 5.5 54962c6389 refactor: explicit imports for table modules (SonarCloud S2208) (#1262)
* refactor: replace wildcard table imports with explicit names (SonarCloud S2208)

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

* style: rustfmt

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:43:36 -04:00
Dylan M. Taylor 6189fa8bf6 perf(engine): bound HTML entity semicolon scan window and formatting stack search (#1267) 2026-10-24 04:41:43 -04:00
Dylan M. Taylor 713f605566 perf(chart): optimize axis plotted extent scan and cache non-bar error bar stats (#1266) 2026-10-24 04:41:23 -04:00
Dylan M. Taylor 9641817a15 security: clamp alpha_num bullet expansion and sanitize filename logging (#1264) 2026-10-24 04:40:29 -04:00
Dylan M. Taylor 1e097e3edd security: use exclusive temporary directories for macOS cert import and media fixtures (#1263) 2026-10-24 04:40:26 -04:00
Dylan M. TaylorandClaude Sonnet 5.5 d94790d97a refactor: replace redundant closures with method paths (SonarCloud S1612) (#1261)
Skipped where the receiver type is generic or not evident without a compiler (wrapping_neg, total_cmp, exists, to_vec, as_ref, split_first, Option::into_inner).

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:27:07 -04:00
aiongg 9c31b567da Integration: #978 media-controls test turns presenter view off so its embedded window does not cover the bar 2026-10-24 07:28:17 +00:00
aiongg 30755b3ff7 Integration: #1244 Notes-button test starts with the pane closed and clicks the gap the language name must not cover 2026-10-24 06:53:15 +00:00
aiongg f639ef986e Integration: #927 Header & Footer dialog: reading the format must not store the default as picked 2026-10-24 06:47:20 +00:00
aiongg ded9094f59 Integration: crates/model/src/chart.rs keeps main's CRLF line endings 2026-10-24 06:29:33 +00:00
aiongg 7c1ae73ead Integration: #565 collapse the nested if in rtf_to_outline (clippy) 2026-10-24 06:29:33 +00:00
aiongg 36cd258bd2 Merge PR #883: PPTX: paragraph builds edited in the reference app keep their clicks
ROADMAP.md: union of log rows. docs/animation-parity.md: main's header; the by-paragraph row from the PR, the triggers row from main; both revision rows.
2026-10-24 06:27:19 +00:00
aiongg 8957e193e9 Merge integration track r1-t02 (dialogs, view, text editing, panes, status bar, other)
ROADMAP.md, docs/gaps.md: union of both tracks' log rows.
2026-10-24 06:26:36 +00:00
aiongg 820b163067 Merge integration track r1-t01 (charts, tables, render, shapes, slides, slideshow, outline formats) 2026-10-24 06:26:23 +00:00
aiongg 80e5506456 Integration: cargo fmt 2026-10-24 06:25:28 +00:00
aiongg 3a37fb6b7e Integration: #565 rtf_to_outline never splits a multi-byte character after \u or \' escapes 2026-10-24 06:18:22 +00:00
aiongg abd8768b65 Merge PR #565: Files: save and open outlines as RTF
Conflicts:
crates/engine/src/cmd/file.rs: export spec keeps main's transparentBackground and adds rtf; probe_clips (main) and outline_text (PR) both kept
docs/file-format-parity.md: PR's outline row (RTF now read) with main's .md wording, main's PNG row
2026-10-24 06:18:03 +00:00
aiongg 882f1cbdbc Merge PR #1075: Indent table rows in outline export so they stay on their slide 2026-10-24 06:17:37 +00:00
aiongg bb5da2036d Merge PR #978: Hide the slide show media controls when the pointer is idle (#967)
Conflicts: crates/ui-egui/src/show.rs (test module: kept both sides' tests)
2026-10-24 06:17:37 +00:00
aiongg 5b5fd5eabe Merge PR #1109: Reuse Slides: choose which slides of the other file to insert
Conflicts: ROADMAP.md (union of both sides' rows)
2026-10-24 06:17:26 +00:00
aiongg 2693336155 Merge PR #809: Design: Browse for Themes brings a template's slide master and layouts (#807)
Conflicts: ROADMAP.md (union of both sides' rows)
2026-10-24 06:17:23 +00:00
aiongg f52c911959 Integration: #927 Header & Footer dialog sends dateFormat only when picked; set_date doc comment back above set_date
Without it, Apply rewrote every slide's existing date field to datetime1.
2026-10-24 06:17:19 +00:00
aiongg 0313e0128a Merge PR #850: Review: reject revisions after Compare, one or all
ROADMAP.md, docs/gaps.md: kept main's lines and the PR's single added line in each table.
2026-10-24 06:16:50 +00:00
aiongg 5880086522 Merge PR #927: Header & Footer: the automatic date takes a format 2026-10-24 06:16:45 +00:00
aiongg b7c505a389 Merge PR #1254: Reject invalid index in slide.hide and slide.layout
Conflicts: crates/engine/src/tests.rs (appended both sides' tests)
2026-10-24 06:16:45 +00:00
aiongg 987f49ac03 Merge PR #1252: Reject '+'-signed numeric character references in HTML paste 2026-10-24 06:16:43 +00:00
aiongg dd9111ba20 Merge PR #384: Web: point web app favicon to deckcraft.ico
contributors/people.toml: kept both new entries.
2026-10-24 06:16:43 +00:00
aiongg 92724e02bb Merge PR #1205: Design Ideas: no ideas for slides with diagrams, connectors or other graphics (#1200)
Conflicts: crates/engine/src/cmd/ideas.rs (test module: kept both sides' tests)
2026-10-24 06:16:39 +00:00
aiongg 0b6ea6a287 Integration: #1244 test that a long language name leaves the Notes button clickable 2026-10-24 06:16:38 +00:00
aiongg 29cc5f83bd Integration: #833 drop the leftover PNG debug hook from the Edit Points menu test 2026-10-24 06:16:27 +00:00
aiongg c480b74a3f Merge PR #833: Shapes: Edit Points — Straight/Curved Segment and Open/Close Path from the right-click menu
Conflicts: ROADMAP.md docs/gaps.md docs/ui-parity.md (union of both sides' rows)
2026-10-24 06:16:21 +00:00
aiongg 2fd32751cc Merge PR #1251: Fix arrange.distribute span to use the selection's full extent 2026-10-24 06:16:21 +00:00
aiongg 78afd2fc45 Merge PR #1245: Title: arrange.group: nest groups at most 32 deep 2026-10-24 06:16:18 +00:00
aiongg b280f7755d Merge PR #846: Canvas: the Format and Animation Painter override the cursor (#830)
Conflicts: crates/ui-egui/src/canvas.rs (cursor match: kept #806's resize_cursor arm inside #846's painter-armed wrapper)
2026-10-24 06:16:18 +00:00
aiongg 84702f3810 Merge PR #806: Canvas: the resize cursor follows the handle's rotated diagonal (#745)
Conflicts: ROADMAP.md (union of both sides' rows)
2026-10-24 06:16:06 +00:00
aiongg be5413826c Merge PR #848: Render: quick styles render on empty placeholder text boxes (#832)
Conflicts: ROADMAP.md (union of rows)
2026-10-24 06:16:06 +00:00
aiongg 2109dfa03c Merge PR #1244: Status bar: keep a long language name from overlapping 2026-10-24 06:15:59 +00:00
aiongg e6f7648219 Integration: #945 allow unwrap and expect in the thumbs tests module 2026-10-24 06:15:59 +00:00
aiongg 3aa8f4b8b1 Merge PR #1016: Render: table body text on tinted cells picks the theme colour that contrasts with the fill, so default tables stay readable on dark themes 2026-10-24 06:15:56 +00:00
aiongg 4f56789f45 Merge PR #945: Thumbnails: navigating slides scrolls the pane to keep the current slide visible (#917)
ROADMAP.md: kept all table lines.
2026-10-24 06:15:56 +00:00
aiongg f2e2fcaba4 Merge PR #1015: Chart text can be coloured (chart.options textColor, format.color) and follows the theme by default
Conflicts: crates/engine/src/cmd/insert.rs (kept main's apply_bin_options / apply_subtotals_and_bars helpers and added the textColor assignment)
2026-10-24 06:15:53 +00:00
aiongg 6971d55345 Merge PR #799: Text boxes: a click on the frame selects the box whole (#787)
docs/ui-parity.md: kept main's status header lines; the PR's older header dropped, its new table row kept.
2026-10-24 06:15:50 +00:00
aiongg aed303e504 Integration: #956 chart.options dataLabels resets the series' own settings
Without it, turning Data Labels on did nothing for an imported chart whose series read showVal 0.
2026-10-24 06:15:41 +00:00
aiongg c7388bd1ed Merge PR #320: Text: Tab no longer ends text entry in lists (#93)
crates/ui-egui/src/lib.rs: keyboard() keeps main's image-paste event scan and adds the PR's focus surrender (one shared modal flag); both new tests kept (typing_goes_on_after_tab_in_a_text_box, tab_in_an_edited_list_keeps_the_keyboard).
2026-10-24 06:15:41 +00:00
aiongg cac379b5ee Merge PR #956: Charts: a series' own data-label setting decides whether its labels show (#868)
Conflicts: crates/model/src/chart.rs (kept series_data_labels and main's waterfall_value_range split; value_range stays where main has it)
2026-10-24 06:15:05 +00:00
aiongg 7fd34fb359 Merge PR #813: engine: a hostile text selection can't drive paragraph commands past the body 2026-10-24 06:15:03 +00:00
aiongg 5827d3b4c1 Merge PR #904: Clamp text.selectParagraph paragraph index to avoid overflow 2026-10-24 06:15:03 +00:00
aiongg f6597e0259 Merge PR #1043: Replace Fonts test: a window wide enough to hold Home's ribbon 2026-10-24 06:15:03 +00:00
aiongg f8a70063f7 Merge PR #965: Tests: Ctrl+A in the slide sorter selects every slide (#936)
ROADMAP.md: kept both table lines.
2026-10-24 06:15:03 +00:00
aiongg 601a20ecf6 Merge PR #805: Preferences: checkboxes and slider rails show in light mode 2026-10-24 06:14:58 +00:00
aiongg ca0e1a300a Merge PR #1208: Theme: text fields show in the light theme (#1204)
crates/ui-egui/src/lib.rs: kept both test modules (field_frame_tests, find_dialog_tests).
2026-10-24 06:14:58 +00:00
aiongg ee721e26dd Merge PR #1198: Make chart axis flatness thresholds relative to data magnitude 2026-10-24 06:14:53 +00:00
aiongg a1c92dc6c5 Merge PR #1146: Charts: bars draw their series' pattern fill and no fill
Conflicts: ROADMAP.md (union of progress-log rows)
2026-10-24 06:14:53 +00:00
aiongg a774f01199 Merge PR #962: Tests: the Options dialog's Cancel, Esc and the close button restore the settings (#878)
crates/ui-egui/src/dialogs.rs: kept both test modules (about_credits_tests, preferences_tests).
2026-10-24 06:14:48 +00:00
aiongg e56c62c260 Merge PR #793: UI: close About dialog on Cancel and test Options Cancel from File tab (#782) 2026-10-24 06:14:39 +00:00
5fadda5ecf chore: resolve SonarCloud maintainability issues (#1255)
* chore: resolve SonarCloud maintainability issues

* fix: address shellcheck SC2059 and fix generic cast ambiguity

* style: apply rustfmt formatting

* fix: restore compile and behaviour in Sonar refactors

- fonts: char::is_alphanumeric takes &self, so filter needs a closure
- h264: keep the mvd accumulator as i32 (prefix is usize)
- model: annotate the non-stacked range accumulators as f64
- engine: chart.options still clears the raw chart XML for upDownBars
  and quartiles; smartart goes back to the glob import (cmd! macro)
- web: keep the original viewport meta (behaviour change)

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

* Assign positional parameters to locals in sha256 verification

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
2026-10-23 23:28:09 -04:00
aiongg b913c4f9d0 PDF: pin craft-pdf and krilla to craft-libs main (#1253)
* PDF: pin craft-pdf and krilla to craft-libs main (PNG decode bound)

* PDF: pin craft-pdf and krilla to craft-libs at 9f75d3dc (#254)
2026-10-23 22:37:28 -04:00
NetterSilverandClaude Haiku 5.5 6d7b32351f Reject invalid index in slide.hide and slide.layout
Found while testing crates/engine/src/cmd/slide.rs:376 (hide); same pattern at slide.rs:445

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 02:16:19 +00:00
NetterSilverandClaude Haiku 5.5 fc914a059c Reject '+'-signed numeric character references in HTML paste
Found while testing crates/engine/src/cmd/richclip.rs:301-304 (numeric_char, used by decode_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 01:23:11 +00:00
NetterSilverandClaude Haiku 5.5 18e8bb0ded Fix arrange.distribute span to use the selection's full extent
Found while testing crates/engine/src/cmd/arrange.rs:325-327 (distribute)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 01:07:34 +00:00
Zach BerryandClaude Fable 5.1 52bf97d983 Render: table body text on tinted cells uses the theme's dark colour, so it stays readable on dark themes
The medium2 (default), medium4 and banded light1 table styles tint body cells towards white
but gave their text no colour, so it inherited the theme's text colour: light on a dark theme
such as Nocturne, and nearly invisible on the light tint. PowerPoint's medium styles set that
text to dk1. DeckCraft's dark themes keep the dark colour in lt1 (Nocturne has dk1 white and
lt1 near-black, relying on the default tx1->dk1 map), so the look picks whichever of dk1 and
lt1 contrasts more with the cell's actual fill. Cells with no fill keep inheriting, and the
"grid" border keeps the text colour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:58:37 -05:00
Zach BerryandClaude Fable 5.1 aaba9febc2 Progress log: chart text colour
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:58:02 -05:00
Zach BerryandClaude Fable 5.1 0c510426af Chart text without a colour of its own takes PowerPoint's default grey
The theme's bare text colour made chart text black on a light theme,
where it used to be 595959. PowerPoint's default is `tx1` at lumMod 65000
and lumOff 35000: 595959 on a light theme, a light grey on a dark one, so
the dark-theme fix stays. The render test now checks the light-theme title
is dark but not black; the chart.options doc says what null gives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:57:50 -05:00
Zach BerryandClaude Fable 5.1 3ac39f9bb6 Chart text colour is written to the chart part, so an imported chart keeps it
`Chart::text_color` was read from a .pptx (the axes', legend's or chart's
`c:txPr`) but never written, and any edit drops the kept part and rebuilds
it from the model, so a save after an edit lost the colour. The writer now
puts it where PowerPoint keeps it: `c:txPr` on both axes, the legend, the
data labels and the chart root, and the run defaults of the title's rich
text (chart and axis titles). Nothing is written without a colour, so the
theme default still applies on reading.

Round-trip tests: a scheme colour with a lumMod, an RGB colour and no
colour come back as set; a chart read with a `tx1` 65 %/35 % colour on its
axis keeps it through a title edit and a rebuild of the part.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:57:50 -05:00
Zach BerryandClaude Fable 5.1 30b7eff487 Pie data labels take the chart's text colour when it has one
A textColor set on a chart (chart.options, or Font Color with the chart
selected) coloured the title, axes and legend but left a pie's data labels
white. They stay white by default, on the slices, and follow a colour the
chart's text was given.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:57:50 -05:00
Zach BerryandClaude Fable 5.1 be9bf48eb2 Chart text without a colour of its own takes the theme's text colour
A chart's title, axis labels, legend and data labels were drawn in a
fixed grey (0x595959) whenever `text_color` was unset, which is
near-invisible on a dark theme. The renderer now resolves the theme's
tx1 instead, so a chart is readable by default on Nocturne as well as
on Harbor; an explicit colour still wins.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:57:50 -05:00
Zach BerryandClaude Fable 5.1 1b03ae95de format.color on a selected chart colours the chart's text
Font Color with a chart selected answered and changed nothing: a chart
has no text runs to colour. It now sets the chart's text colour (title,
axes, legend and data labels), the same thing chart.options {textColor}
sets; a mixed selection still colours the other shapes' runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:57:50 -05:00
Zach BerryandClaude Fable 5.1 a48a2d706a chart.options takes textColor so a chart's text can be coloured
On a dark theme a chart's title, axis labels, legend and data labels
render in a fixed grey and nothing could change them, though the model
already carries `text_color`. `chart.options {textColor}` now sets it
(#RRGGBB, accent1…accent6, tx1…, or null for the default) and refuses a
string that isn't a colour. docs/mcp.md mentions it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:57:50 -05:00
aiongg 9aef21e627 Media: a clip opened from a .pptx gets its length and size from its headers (#1241) (#1250) 2026-10-23 19:56:24 -04:00
aiongg 7626f74b99 Media: use craft-media for bitstream, isobmff and matroska (#1034)
* Media: use craft-media for bitstream, isobmff and matroska

deckcraft-bitstream, deckcraft-isobmff and deckcraft-matroska re-export
craft_media::{bitstream, isobmff, matroska} from craft-libs. The packages
and their public paths stay, so the codec crates build unchanged.
deckcraft-bitstream keeps the decoder memory budget as its own module
(budget). deckcraft-media names the new CodecConfig::Apv entry.

* Media: pin craft-media to storytold/craft-libs at f97c84d2 (#245)

* Media: pin craft-media to storytold/craft-libs at f0ad6c1e (#246)
2026-10-23 19:29:43 -04:00
NetterSilverandClaude Sonnet 5.5 8789394de1 fix: keep chart axis steps positive and finite and drop absolute floors in rendering
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 01:21:54 +02:00
NetterSilverandClaude Sonnet 5.5 9b2ab3d2b8 test: give outline table-row regression test a titled slide and assert one slide
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 01:07:54 +02:00
KataccandClaude Opus 5.5 8117d5b710 arrange.group: nest groups at most 32 deep
Grouping a group again and again had no limit, but a .deckcraft saved
with 39 or more nested groups could not be opened again (the JSON
reader's recursion limit), and past 64 levels shape walks such as
format.languageAll stop. arrange.group now refuses to nest past 32,
the depth the PPTX reader reads and the renderer draws, and leaves the
slide unchanged. The test builds the deepest allowed deck (a table at
the bottom) and checks it saves and reopens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:02:21 +03:00
KataccandClaude Opus 5.5 50667e925e Doc comment for the status bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:02:04 +03:00
KataccandClaude Opus 5.5 284db01d5e Status bar: clip the language label before the controls on the right
In a narrow window a long language name ("… (not checked)") painted
over the Notes and Comments buttons, and its click area covered them.
The label is now clipped where the controls begin, and only its visible
part opens Review › Language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:02:04 +03:00
KataccandClaude Opus 5.5 8212c1a805 Status bar: keep the save message clear of a long language name
The transient message ("Saved") was painted at a fixed x of 290, so
"English (United States) (not checked)" ran under it. The language and
the message now each start at their usual column or after the item
before them, and the message is clipped before the controls on the
right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:02:04 +03:00
Brandon ThomasandClaude Opus 5.5 a58bdbc756 Docs: Apply to All reaches the slide masters (#891); keep gaps.md a list of shortfalls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:28:02 -04:00
Brandon Thomas 10d911cdd6 Merge pull request #1037 from FabPei/agent/issue-955 2026-10-23 17:22:14 -04:00
Brandon Thomas 7871ff3d62 Merge pull request #1076 from fchinu/format_text 2026-10-23 17:22:14 -04:00
Brandon Thomas 8b912c106e Merge pull request #1002 from Silvertree2010/fix/h2821-find-replace-ignores-line-breaks 2026-10-23 17:22:13 -04:00
Brandon Thomas 37ac38447d Merge pull request #896 from Bryson14/feat/symbol-font-mac-remap 2026-10-23 17:22:13 -04:00
Brandon Thomas a7876aab54 Merge pull request #1097 from Silvertree2010/fix/h1120-text-set-keeps-a-stray-carriage 2026-10-23 17:22:12 -04:00
Brandon Thomas e282b90185 Merge pull request #1191 from MarekSpodBiedry/issue-1130 2026-10-23 17:22:12 -04:00
Brandon ThomasandClaude Opus 5.5 4ada0efac2 Tests: Find and Replace across a line break through Session::execute
Covers find offsets, no match spanning the break, replace and undo
(CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:22:11 -04:00
Brandon ThomasandClaude Opus 5.5 8c55529df9 Format the Symbol table tests (cargo fmt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:21:03 -04:00
Brandon Thomas 137685b098 Merge pull request #986 from HyakoV3/fix/974-modern-comment-namespaces 2026-10-23 17:13:40 -04:00
Brandon ThomasandClaude Opus 5.5 07cbe5c908 README: Alpine's AMD Vulkan driver is mesa-vulkan-ati; -wlr has no file chooser
CodeRabbit review notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:13:32 -04:00
Brandon ThomasandClaude Opus 5.5 a5ca7d04b0 Format Painter: walk only the paragraphs a text selection has
An unclamped hostile selection end (text.select caret [1e18, 0]) made
covered_paragraphs iterate for effectively forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:13:32 -04:00
Brandon ThomasandClaude Opus 5.5 b6bd10e0a8 text.set: a lone CR is a paragraph break too
Matches edit::insert, which already maps a bare \r to \n.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:13:31 -04:00
Brandon ThomasandClaude Opus 5.5 c7a1423105 Presenter View: closing its window also closes its slide grid
Otherwise presenter_grid stayed true and held auto-advance and rehearsal for the
rest of the show (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:13:31 -04:00
Brandon ThomasandClaude Opus 5.5 e294a09664 Text: apply the Symbol encoding table only to the Symbol face
A Wingdings or Webdings face without private-use coverage no longer gets
Symbol's mapping (0xD8 drew as ¬).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:13:31 -04:00
Brandon Thomas 3545a0a995 Merge main (integration tests moved into tests/it) 2026-10-23 17:12:52 -04:00
Brandon ThomasandClaude Opus 5.5 9861de3c29 Media: a negative (padding) video crop keeps the letterboxed frame
The format repair clamps a crop to 0..1, but an unrepaired negative value still
took the stretch path in paint_frame (CodeRabbit on #1008).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:11:34 -04:00
Brandon Thomas 48066c4674 Merge pull request #1008 from MarekSpodBiedry/issue-972
# Conflicts:
#	crates/ui-egui/src/show.rs
2026-10-23 17:11:34 -04:00
Brandon Thomas 964d470122 Merge pull request #1091 from MMexicann/fix/empty-number-format-sections
# Conflicts:
#	crates/model/src/numfmt.rs
2026-10-23 17:11:13 -04:00
Brandon Thomas 8f81cdc380 Merge pull request #1026 from Silvertree2010/fix/1020-bug-report-deckcraft-gifs-stop-p
# Conflicts:
#	crates/ui-egui/src/media.rs
2026-10-23 17:11:03 -04:00
Brandon Thomas a64487ca2c Merge pull request #1148 from Silvertree2010/fix/h1231-trendline-equation-label-shows-y 2026-10-23 17:10:54 -04:00
Brandon Thomas 5018f53391 Merge pull request #1099 from Silvertree2010/fix/h1167-scientific-number-format-prints 2026-10-23 17:10:54 -04:00
Brandon Thomas bfb1362cef Merge pull request #1054 from Silvertree2010/fix/h738-histogram-with-a-fixed-bin-count 2026-10-23 17:10:53 -04:00
Brandon Thomas 7ada390639 Merge pull request #1035 from Silvertree2010/fix/h601-general-number-format-writes-lar 2026-10-23 17:10:53 -04:00
Brandon Thomas e452a751c1 Merge pull request #1036 from Silvertree2010/fix/h639-number-format-thousands-scaling 2026-10-23 17:10:52 -04:00
Brandon Thomas c6c86e7a3f Merge pull request #1085 from Silvertree2010/fix/1083-rounded-corners-do-not-scale-cor 2026-10-23 17:10:43 -04:00
Brandon Thomas e27d549be7 Merge pull request #1134 from pick2510/fix/3d-in-scaled-groups 2026-10-23 17:10:42 -04:00
Brandon Thomas a0aca7f36b Merge pull request #1187 from samirhvbr/fix/group-shape-appearance 2026-10-23 17:10:42 -04:00
Brandon Thomas b2017dcb47 Merge pull request #1233 from marcoarnulfo/fix/set-transparent-color-pick 2026-10-23 17:10:41 -04:00
Brandon Thomas 4030f3c008 Merge pull request #1234 from marcoarnulfo/feat/last-fill-outline-color 2026-10-23 17:10:41 -04:00
Brandon Thomas a7c4894471 Merge pull request #1239 from marcoarnulfo/feat/canvas-scrollbars 2026-10-23 17:10:40 -04:00
Brandon Thomas 1d6205d576 Merge pull request #1240 from leobasilio/fix/textbox-glow-halo 2026-10-23 17:10:39 -04:00
Brandon Thomas 274a55e812 Merge pull request #1101 from Silvertree2010/fix/1094-partial-circle-preset-missing-ex 2026-10-23 17:10:31 -04:00
Brandon Thomas 7e5b21b794 Merge pull request #1136 from pick2510/fix/spdef-anchor-new-shapes-only 2026-10-23 17:10:31 -04:00
Brandon Thomas fe9674297a Merge pull request #1160 from Hamza0101/test/thumb-render-counter 2026-10-23 17:10:30 -04:00
Brandon Thomas 836313560e Merge pull request #1193 from MarekSpodBiedry/issue-1178 2026-10-23 17:10:30 -04:00
Brandon Thomas b339ba490d Merge pull request #1027 from Silvertree2010/fix/h261-rgba-from-hex-accepts-signs-insi 2026-10-23 17:10:14 -04:00
Brandon Thomas 1763368843 Merge pull request #1056 from oracola91/fix/package-licence-files 2026-10-23 17:10:13 -04:00
Brandon Thomas 2736193d31 Merge pull request #1039 from Hamza0101/control-test-slots 2026-10-23 17:10:12 -04:00
Brandon Thomas e6527e5c2c Merge pull request #1060 from oracola91/fix/control-accept-backoff 2026-10-23 17:10:12 -04:00
Brandon Thomas 2d7b69d7b6 Merge pull request #1199 from marcoarnulfo/feat/anim-play-from 2026-10-23 17:10:12 -04:00
Brandon Thomas 8485b10356 Merge pull request #1207 from xc4pr/fix/1201-ideas-band-reset 2026-10-23 17:10:11 -04:00
Brandon Thomas e8e36be177 Merge pull request #894 from kk376/fix/apply-to-all-master-background 2026-10-23 17:09:50 -04:00
Brandon Thomas 0f00a94c23 Merge pull request #906 from MarekSpodBiedry/issue-843 2026-10-23 17:09:50 -04:00
Brandon Thomas 5bebcd9075 Merge pull request #872 from kinower/fix/responsive-mouse-wheel-navigation 2026-10-23 17:09:49 -04:00
Brandon Thomas 998429fb9e Merge pull request #820 from Silvertree2010/fix/h1617-paste-from-html-a-newline-inside 2026-10-23 17:09:49 -04:00
Brandon Thomas f5e9cf5b0a Merge pull request #999 from Faabyy/fix/media-clip-per-slide-994 2026-10-23 17:09:41 -04:00
Brandon Thomas 9057bb7ef0 Merge pull request #1009 from MarekSpodBiedry/issue-1005 2026-10-23 17:09:40 -04:00
Brandon Thomas 6498809e29 Merge pull request #1161 from kbarni/faster-transition 2026-10-23 17:09:40 -04:00
Brandon Thomas eefb1ac380 Merge pull request #1211 from marcoarnulfo/fix/presenter-notes-bullets 2026-10-23 17:09:39 -04:00
Brandon Thomas ddeef2d9a6 Merge pull request #1228 from marcoarnulfo/feat/show-start-from 2026-10-23 17:09:39 -04:00
Brandon Thomas 1cb436bcbb Merge pull request #1235 from MarekSpodBiedry/issue-1229 2026-10-23 17:09:38 -04:00
Brandon Thomas e128a09caa Merge pull request #988 from Zetenypapp/fix/smart-duplicate-966
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-23 17:09:38 -04:00
Brandon Thomas 3084f5b5f4 Merge pull request #989 from Zetenypapp/fix/find-focus-951 2026-10-23 17:09:37 -04:00
Brandon Thomas 532d88cda1 Merge pull request #1003 from Faabyy/fix/resize-snaps-dragged-edge-961 2026-10-23 17:09:37 -04:00
Brandon Thomas e676b823f9 Merge pull request #1007 from MarekSpodBiedry/issue-971 2026-10-23 17:09:36 -04:00
Brandon Thomas 7d80f052fb Merge pull request #1064 from Silvertree2010/fix/h848-xml-text-escaping-writes-carriag 2026-10-23 17:09:24 -04:00
Brandon Thomas 86f6b365f3 Merge pull request #1145 from pick2510/fix/table-text-other-style 2026-10-23 17:09:24 -04:00
Dylan M. Taylor 8a1a1ee698 Merge pull request #1243 from dylanmtaylor/ci/sonar-main-not-cancelled
ci(sonar): stop cancelling main-branch runs so coverage is published
2026-10-23 16:59:21 -04:00
Dylan M. Taylor 4b6ff7d324 ci(sonar): don't cancel superseded runs on main, so coverage is published 2026-10-23 16:51:48 -04:00
LeonardoandClaude Sonnet 5.5 8aa76142c9 Render: a shape with no fill and no line draws no glow round its box
A text box with a glow in its shape properties but no fill and no line got a
blurred ring round the whole box; PowerPoint shows only the text's own glow.
The glow of a frame with a line, and of a filled shape, is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 17:06:47 -03:00
Marco Arnulfo 070cee617e Set Transparent Color: take the pixel of the topmost selected picture, not the first one selected 2026-10-23 21:17:27 +02:00
midasmatterhorn 077682ed4c engine: merge main and cover direct hostile paragraph selections 2026-10-23 21:22:14 +03:00
aiongg c787f4f080 Packaging: pin the signing client and appimagetool, and check their hashes (#1082)
* Packaging: pin the Windows signing client and check its hash

* Packaging: pin appimagetool and check its hash

* Fail clearly when no appimagetool is pinned for the architecture

* Pin the AppImage runtime
2026-10-23 13:40:57 -04:00
xc4pr ebf67f8a9a Design Ideas: switching ideas also removes a band saved by earlier versions (#1201) 2026-10-23 19:26:08 +02:00
Marco Arnulfo b7ea777be7 Set Transparent Color: map the click through the picture's 3-D camera and find grouped pictures that keep no box of their own 2026-10-23 19:16:09 +02:00
Marco Arnulfo 8a75773988 Canvas: only the primary button drags a scroll bar thumb, the grab ends with the drag and follows a zoom mid-drag (#1157) 2026-10-23 19:12:35 +02:00
xc4pr 8a61586585 Design Ideas: store the Full-bleed band's opacity as PPTX does, so switching ideas still removes it after a save (#1201) 2026-10-23 19:08:09 +02:00
Marco Arnulfo 9a3db3840a Canvas: a scroll bar press decides thumb or track where it went down, and the thumb follows the grab point (#1157) 2026-10-23 19:04:30 +02:00
aiongg 5bdea79f5c CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#1214)
ci.yml calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD and Windows ARM64 run nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.
2026-10-23 12:59:34 -04:00
xc4pr d86f7aed75 Theme: the field test only counts a fill or outline that is actually painted (#1204) 2026-10-23 18:52:55 +02:00
aiongg f001f37906 CI: one test binary per crate, nextest, line tables only (#1213)
* Build: line tables only for workspace crates, no debug info for dependencies

* Tests: one integration-test binary per crate

* xtask ci: nextest, doctests, test build without examples, one wasm check
2026-10-23 12:43:34 -04:00
xc4pr 49f296cca1 Design Ideas: Full-bleed puts the band on the visible title, not a hidden one (#1201) 2026-10-23 18:25:46 +02:00
Marco Arnulfo f43d42d535 Canvas: scroll bars when the zoomed slide is larger than the window (fixes #1157) 2026-10-23 18:21:28 +02:00
xc4pr e126fad9a7 Design Ideas: decide whether an empty box shows from its resolved fill and outline (#1200) 2026-10-23 18:17:39 +02:00
MarekSpodBiedry ac0130d2d7 Keep the slide show in a window for "Browsed by an individual" (#1229) 2026-10-23 17:31:00 +02:00
Dylan M. Taylor 0f4fffcf6c Use chrono instead of hand-rolled UTC conversion (#1116)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled civil_from_days / civil copies and the hand-written
weekday / leap-year / days-in-month helpers with chrono. chrono is already a
workspace dependency (used by crates/text), so this reuses that entry and just
adds `chrono = { workspace = true }` to the engine and desktop app rather than
a second version.

- apps/deckcraft logging `timestamp` now formats through chrono (private
  civil_from_days deleted).
- engine cmd::design `civil` (days since 1970 -> date, used for comment
  timestamps in review.rs) uses chrono::NaiveDate.
- text datetime `DateTime::is_valid` / `weekday` use chrono::NaiveDate instead
  of a hand-rolled leap-year table and days-since-1970 weekday.

Output is byte-identical and pinned by the existing logging, design and
datetime-field tests; each converted helper keeps a proleptic-Gregorian
fallback for dates outside chrono's NaiveDate range.

Follow-up to storytold/cadcraft#1785

* text: make chrono available on wasm32

is_valid/weekday in crates/text/src/datetime.rs use chrono unconditionally,
but chrono was only declared under the non-wasm target, so the crate (and
engine/pdf/render/ui-egui depending on it) failed the wasm32-unknown-unknown
check in `cargo xtask ci`.

- crates/text: declare chrono unconditionally (wasm-safe `std` baseline),
  and keep `clock` in a separate cfg(not(target_arch="wasm32")) entry for
  now_local()'s local clock, which is already wasm-gated to return None.
- workspace: drop `clock` from the shared chrono entry so it is no longer
  forced onto wasm builds (engine uses chrono unconditionally too); `clock`
  is now requested per-crate only where the local clock is read.

* Never panic on a clock outside chrono's range; harden the civil fallback

logging.rs timestamp(): chrono's From<SystemTime> unwraps, so a clock past
year 262143 panicked inside the logger. Use the fallible DateTime::from_timestamp
and read an out-of-range or pre-epoch clock as the epoch, as the old saturating
code did. Years past 9999 are written with {:04} like before, because %Y adds a
'+'. Tests cover both cases.

cmd/design.rs civil_fallback(): make the hand-rolled fallback's year arithmetic
saturating so civil() cannot overflow or panic at i64::MAX/MIN (the engine rule
forbids crashes and unchecked arithmetic). Behaviour is unchanged for every real
input; a test exercises both extremes. The output year is formatted with {y:04}
by review.rs, so no %Y prefix issue.

crates/text/src/datetime.rs: checked after review; is_valid already limited the
year to 1..=9999 before this PR, weekday() is only reached for valid dates, and
all its arithmetic is checked/euclidean, so no change is needed.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* Make civil() exact for every i64 instead of saturating

The out-of-range fallback saturated z + 719_468, so civil(i64::MAX)
returned a date 719,468 days early. civil() now has chrono convert the day
within its 400-year Gregorian cycle and adds whole cycles back as years
(i128 for the extremes), so the hand-rolled fallback is gone. Exact results
for i64::MAX/MIN and both sides of chrono's range are tested against Hinnant's formula.

* time: preserve out-of-range years in the log timestamp instead of the epoch

`DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: a
clock set far ahead (an input the old hand-rolled civil-from-days converted)
collapsed to `1970-01-01`. Restore the old conversion domain with a cycle-based
helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the
in-range remainder with chrono, and carry the year back as i128. Only the year
leaves chrono's range; month/day/time stay exact. Pre-1970 still clamps to the
epoch as before. chrono stays the only calendar logic (no new features).

apps/deckcraft logging.rs `timestamp` (SystemTime, u64 secs), pinned via a
platform-safe `UNIX_EPOCH.checked_add`:
  u64::MAX/2 s → "292277026596-12-04T15:30:07.000Z" (was "1970-01-01..."),
  10e12 s → "318857-05-20T17:46:40.000Z", 253_402_300_800 s → "10000-01-01...".

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-23 11:20:42 -04:00
Dylan M. Taylor bdcb532608 packaging: [[ ]] tests, named positional parameters, explicit returns and a default case (SonarCloud S7688, S7679, S7682, S131) (#1212)
Use [[ ]] for conditional tests in the bash packaging scripts, give positional
parameters a name before use, end the shared helpers in common.sh with an explicit
return (bare return keeps the last command's status), and give the FreeBSD arch
case a no-op default. No behaviour change.
2026-10-23 11:16:44 -04:00
Marco Arnulfo de18659978 Pictures: Set Transparent Color refuses a spot that is already transparent (#1217) 2026-10-23 17:14:51 +02:00
Marco Arnulfo 58d746bc17 Shape Outline button sets only the colour; docs row under Revision history; tests anchor on the hex field's label and check the scheme slots 2026-10-23 17:07:21 +02:00
Marco Arnulfo 9eacd994bb Pictures: Set Transparent Color picks the colour by clicking the picture, as in PowerPoint (fixes #1217) 2026-10-23 17:06:58 +02:00
Marco Arnulfo 40f6f1756b Shape Fill and Shape Outline: the button applies the last colour picked from its menu (part of #1080) 2026-10-23 17:01:26 +02:00
MarekSpodBiedry 3a917bb4e0 Keep each window's Esc, G and auto-advance to its own All slides grid (#1130) 2026-10-23 16:08:15 +02:00
Marco Arnulfo 90d40e2ecc Show: refuse a show.start from that is not a slide
show.start {from} turned a negative or fractional index into slide 0 and an index past the end into a show that opened on the end screen, over the command and the control channel alike. Both now refuse it, as the slide commands do since #1063; a hidden slide still plays.
2026-10-23 14:48:45 +02:00
Marco Arnulfo da9bbac5a0 Play From: skip triggered effects, reject an invalid from
- The pane picks the first effect of the selected shape that is on the click sequence, so a
  triggered effect before it no longer turns the button back into Play All; a clicked triggered
  row falls back the same way.
- animation.preview rejects a from that is not an effect index instead of playing all.
- target-app-parity: the Animation Pane timeline is no longer a shortfall.
2026-10-23 10:22:51 +02:00
fchinuandClaude Sonnet 5.5 da3e89695f Format Painter: paint a drag release only if the drag changed the selection
A Shift-press at the caret inside the edited text starts a text drag
without changing the selection; releasing it no longer repaints the
existing range.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 09:34:39 +02:00
fchinuandClaude Sonnet 5.5 6e201094c6 Format Painter: compare the whole text target when a click selects text
A double or triple click that moves the selection to another shape or
cell at the same offsets now counts as a change and paints.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 09:20:40 +02:00
Marco Arnulfo c0b405c42a Presenter View: speaker notes keep their bullets, numbers and formatting (fixes #1128) 2026-10-23 03:54:04 +02:00
xc4pr ced5dc04ed Design Ideas: recognise the band by its exact fill, not a saved tag (#1201) 2026-10-23 02:41:46 +02:00
xc4pr 6d312e0722 Design Ideas: an empty text box that shows also blocks the ideas (#1200) 2026-10-23 02:27:59 +02:00
xc4pr f330a02970 Design Ideas: mark the Full-bleed band so a fill the user set is kept (#1201) 2026-10-23 02:19:55 +02:00
xc4pr b61898bba5 Theme: text fields show in the light theme (#1204)
In the light theme a text field had the same white fill as the dialog,
pane or ribbon it sits on and no outline (nor a visible focus outline),
so only its label or hint showed. Fill text fields with a light grey, as
the dark theme already sets them apart with a darker well. The File tab
keeps its outlined white fields.
2026-10-23 02:00:59 +02:00
xc4pr adcfa70b4a Design Ideas: no ideas for slides with diagrams, connectors or other graphics (#1200) 2026-10-23 00:45:54 +02:00
xc4pr 7858d589cd Design Ideas: switching ideas removes the Full-bleed title band (#1201) 2026-10-23 00:45:54 +02:00
NetterSilverandClaude Haiku 5.5 53972cf37c Make chart axis flatness thresholds relative to data magnitude
Found while testing crates/model/src/chart.rs:775-794 (axis_scale); same pattern in nice_scale :817

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:07:43 +00:00
Marco Arnulfo 19ebd9c2dc Animation Pane: Play From the selected effect
With an effect selected the pane's play button reads Play From and the
canvas preview starts at that effect on the play-all clock, so the
effects before it have already played. With nothing selected it is
still Play All. animation.preview takes an optional from index.
2026-10-22 23:03:35 +02:00
Brandon ThomasandClaude Opus 5.5 b47195feec Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:37:20 -04:00
MarekSpodBiedry bde35fab11 Show All slides in the Presenter View window, not on the audience screen (#1130) 2026-10-22 21:10:16 +02:00
MarekSpodBiedry c4a459d1ca Cap the PDF slide raster by total pixel count (#1178) 2026-10-22 21:03:42 +02:00
Samir Hanna Verza 07c8e2d77f M3: apply fill and outline edits to group members (#1079) 2026-10-22 13:20:11 -03:00
Keresztes BarnaandClaude Opus 5.5 df0fab857d show: keep the document a slide backdrop was rendered from
The backdrop cache knew its document by address only: after an edit
during the show, a new copy could reuse the freed address and get the
old backdrop. The cache now holds that document and matches on it.
(CodeRabbit review on #1161.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:59:53 +02:00
Hamza 42e89b347d Thumbnails: count the renders, not only the thumbnails held (#969)
Follow-up to #1011, as asked on #1038: a counter of thumbnails actually
rendered, and tests that use it.

`Textures::thumb_count` is the number of thumbnails *held*. It settles at the
rows in view whether the pane rendered them once or a thousand times, so it
cannot see a pane that renders the same rows over and over -- which is what
#969 was: the flashing white was the rendering, not the holding.

`Textures::thumb_renders` counts the renders, and a settled pane is one that
has stopped asking for them.

The two new tests assert what the held count cannot:

- every thumbnail held was rendered, and not many more than that were rendered
  and thrown away (measured: 8 held, 8 rendered at rest);
- ten more frames of the same view render nothing at all;
- after a 40,000 pt scroll of an 800-slide deck, 24 renders in total -- the
  rows it landed on and the ones it passed over, not the deck -- and then it
  settles again.

With the cache made to thrash (its 600-entry limit cut to 4, so the rows in
view are evicted and re-rendered every frame), the merged test for the Slide
Sorter still passes -- the held count settles at 4 -- while this one fails with
"255 renders for 4 thumbnails". That is the hole the counter closes.
2026-10-22 19:20:07 +05:00
skylar taylor-barrick a2435383b1 Merge pull request #996 from Silvertree2010/fix/h2800-deleting-a-shape-that-is-an-anim
Clear animation triggers that point at deleted shapes
2026-10-22 07:50:16 -06:00
skylar taylor-barrick d01254cf2d Merge pull request #1071 from Silvertree2010/fix/h923-opc-target-resolution-cuts-a-par
Strip URI fragment before percent-decoding OPC targets
2026-10-22 07:50:09 -06:00
skylar taylor-barrick 3f399dd450 Merge pull request #1063 from Silvertree2010/fix/h886-slide-delete-with-an-invalid-neg
Reject invalid slide index params instead of using the selection
2026-10-22 07:50:01 -06:00
skylar taylor-barrick 8f04a999b9 Merge pull request #790 from Hamza0101/fix/empty-line-height
Text: an empty line takes the size of the mark that ends it
2026-10-22 07:49:55 -06:00
skylar taylor-barrick f011313a3a Merge pull request #1011 from Faabyy/fix/thumbs-visible-only-969
Thumbnails: only render the slides in view (#969)
2026-10-22 07:49:48 -06:00
skylar taylor-barrick 792829ec41 Merge pull request #982 from alexeyvolkoff/pr-fix-wasm-startup
fix(wasm): avoid std services that panic on wasm32-unknown-unknown at…
2026-10-22 07:49:40 -06:00
BFlat 4a1d27a227 Merge pull request #1158 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:01:23 +07:00
bflatastic 0b8a4cc23e README: the app icon above the title 2026-10-22 17:57:31 +07:00
Keresztes BarnaandClaude Opus 5.5 31a828b027 show: keep the slide backdrop while animations play
Each animation frame re-rendered the whole slide, backdrop included. A
full-slide layout picture with an 18 pt glow cost ~345 ms of the ~390 ms
frame (2-3 fps at 1080p). Frames now draw only the slide's shapes over
the backdrop, which is rendered once, ahead of time while the slide
rests: ~28 ms a frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:11:09 +02:00
NetterSilverandClaude Opus 5.5 1dd96d3ddc Use a relative coefficient threshold in trendline equations
Found while testing crates/model/src/trendline.rs:~507 (poly_equation)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:03:48 +02:00
kinowerandClaude Opus 5.5 dffef33960 UI: drop the wheel carry whenever another presentation is drawn
The presentation check on the leftover wheel fraction ran only while the
canvas was hovered and fitted, so switching to another presentation from
the tabs and back kept the old fraction. It now runs every frame the canvas
draws a presentation, before the hover and overflow branches.

Test: 0.75 of a line in one presentation, a switch away and back with the
pointer off the canvas, then 0.25 does not turn the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:52:50 +02:00
Dominik StrebelandClaude Opus 5.5 7f2955be1c Text: a table in a content placeholder keeps the other-text style
A table can sit in a content placeholder (its graphicFrame carries
<p:ph idx="1"/>). PowerPoint for Mac still draws its text in the master's
other-text style: in a synthetic deck with a bulleted 36 pt bodyStyle, a
14 pt otherStyle and a 26 pt defaultTextStyle, a table in the placeholder
and one outside it both draw 14 pt cells with no bullet or body indent,
also when the layout's and master's body placeholders carry 30 and 32 pt
list styles.

master_list_style now checks for a table before a placeholder, and
level_style takes nothing from placeholder parents for a table, so the
table shape itself (its formatting state) resolves as its cells do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:10:13 +02:00
Dominik StrebelandClaude Opus 5.5 08b7b5eb15 Charts: bars draw their series' pattern fill and no fill
A bar series' own fill was read only when solid or a gradient: a pattern
(a:pattFill) was dropped, and an a:noFill series was read but drawn in the
palette's colour anyway, because the chart renderer turned every fill into
one colour and fell back to the palette for anything but solid. PowerPoint
for Mac draws the pattern, leaves an a:noFill bar empty and draws its
outline (c:spPr/a:ln) where it has one: a synthetic column chart with a
red-on-yellow dkDnDiag series, an empty series with a 3 pt blue outline
and an empty one without, exported as PDF, shows exactly that, legend
keys included.

- The chart reader reads a:pattFill (dml::pattern, shared with shapes);
  the chart writer writes it back for an edited chart.
- Bars and their legend keys paint with the series' or point's own fill:
  a colour, a preset pattern (pattern::fill) or nothing, then the series'
  solid outline at its width.
- Lines, areas and markers, which take one colour, use a pattern's
  foreground colour instead of the palette's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:02:09 +02:00
Dominik StrebelandClaude Opus 5.5 aa6f45ba2c Text: table text takes the master's other-text style
PowerPoint for Mac lays out a table's text in the master's other-text
style (p:otherStyle), while shapes and text boxes take the presentation's
default text style (p:defaultTextStyle). A synthetic deck with an
otherStyle of 14 pt and a defaultTextStyle of 26 pt, exported as PDF,
draws a table cell at 14 pt and a shape's and a text box's text at 26 pt.
DeckCraft laid cells out through a stand-in shape that wasn't a table, so
they took the default text style: a deck whose two styles differ (18 and
20 pt) drew its table text 2 pt too large and wrapped its cells.

master_list_style sends text in a non-placeholder table to otherStyle,
and Shape::table_cell builds the stand-in every cell layout uses (drawing,
row heights, text placement, the caret and selection in a cell) as a
table. The table shape itself resolves the same way, so the formatting
state of a selected table reports the size its cells draw at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:49:42 +02:00
kinowerandClaude Opus 5.5 e11c7e4d7e UI: reset the fractional wheel carry when the presentation changes
The leftover fraction of a wheel line is now stored with the active
document's uid and dropped when another presentation becomes active, so a
quarter line left in one deck can no longer turn a slide in another.

Test: 0.75 of a line in one presentation followed by 0.25 in a new one
does not turn the page; a whole line in the new one does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:09:23 +02:00
Dylan M. Taylor 128cb0b883 Merge pull request #1139 from storytold/sonar-fix/enforce-https
security: enforce HTTPS on curl downloads (SonarCloud S6506)
2026-10-21 20:13:49 -04:00
kinowerandClaude Opus 5.5 8ddb977733 UI: accumulate fractional mouse-wheel lines into whole slide steps
High-resolution and free-spin wheels on Windows send fractions of a line
(e.g. 0.125 per event); rounding each one up turned a single notch into
several slides. The fractional line delta now accumulates in ui.data temp
state, resets when the direction changes, and each whole line is one
slide. slide.go is skipped when the target is the current slide.

Test: eight 0.125-line events turn exactly one slide, and a reversal
drops the leftover fraction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:01:48 +02:00
kinower 7dce06dca4 UI: keep rapid mouse-wheel slide navigation steps 2026-10-22 00:43:03 +02:00
Dylan M. Taylor e149f32208 security: enforce HTTPS on curl downloads (SonarCloud S6506)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 17:06:39 -04:00
Dylan M. Taylor 354087ecd5 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:30 -04:00
Dylan M. Taylor 4113361660 ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:46:52 -04:00
Dominik StrebelandClaude Opus 5.5 30d00468e2 PPTX: a theme's spDef anchor is for new shapes, not the file's own
A shape whose text has no anchor of its own (`<a:bodyPr/>`) was given the
anchor of the theme's shape defaults (`a:objectDefaults/a:spDef/a:bodyPr`),
which Office's themes set to `ctr`, so its text was centred in the box.
PowerPoint for Mac draws such text at the top: a synthetic deck with an
spDef anchor="ctr" and three rectangles (`<a:bodyPr/>`, anchor="t",
anchor="ctr"), exported as PDF, puts the first two at the top. The spDef
defaults are for shapes inserted later.

Opening a file now anchors every shape without an anchor at the top, as
the standard has it; the model's middle default is still written as ctr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:05:25 +02:00
Dominik StrebelandClaude Opus 5.5 7919429652 Render: measure 3-D point sizes without the camera's turn
shape_at folds a 3-D camera's turn into the shape's transform, so
per_point() measured its foreshortening as if it were a group's
child-coordinate scale: in a 1:1 group, a shape under a turned camera
got a factor other than 1 and drew its extrusion depth, contour and
bevel at the wrong size. The factor is now measured on the transform
before the turn (m with the camera's affine taken back out), and the
turned transform still draws the geometry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:02:17 +02:00
Dominik StrebelandClaude Opus 5.5 48dbdffe07 Render: 3-D sizes in a scaled group stay in points
A group's child coordinates scale its shapes' boxes, but extrusion
depths, contour widths and bevel sizes are points, as PowerPoint draws
them. The renderer applied them in the child space, so the group's scale
multiplied them: a 1 pt contour on a small extruded cube in a legacy
group (1360 child units across a 61 pt group) was drawn hundreds of
points wide, a band across the whole slide, and the cube's extrusion ran
off towards the vanishing point.

per_point() gives the shape-space units per point (the inverse of the
child-coordinate scale that line_space already separates for line
widths); the extrusion depth, contour width and bevel width and height
are converted with it before drawing. Outside a group it is 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:47:33 +02:00
Dylan M. Taylor 5a76518ddd ci: add SonarCloud scan workflow and config 2026-10-21 13:55:12 -04:00
skylar taylor-barrick 9ad2cf3be8 Merge pull request #432 from Lexx143/fix/macos-finder-open
macOS: open presentations from Finder, Open With and the Dock (#138, #347)
2026-10-21 10:39:36 -06:00
fchinuandClaude Sonnet 5.5 f71ce3fa9d Format Painter: paint paragraph formatting per covered paragraph
Paragraph properties now go only onto paragraphs the selection covers
whole, so partly covered end paragraphs and the paragraph after a
triple-click keep their bullets. Double and triple clicks apply the
painter only when they changed the text selection.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 13:46:05 +02:00
FaabyyandClaude 3cd6a815ca Roadmap: record reuse-picker-1103
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 08:14:34 +00:00
FaabyyandClaude fdfdb9e058 Reuse Slides: choose which slides of the other file to insert (#1103)
Reuse Slides… used to insert every slide of the picked presentation. It now opens a list
of that presentation's slides (number and title, all ticked), with Select All / Select None
and a Keep source formatting option, and inserts only the ticked ones after the current
slide. New query slide.reuseList {path | data} gives the list; slide.reuse already took
`slides`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 08:04:19 +00:00
Dylan M. Taylor 63831cdfa9 Merge pull request #865 from dylanmtaylor/ci/shared-nix-workflow
ci: switch to shared nix workflow from craft-common-actions
2026-10-21 03:40:14 -04:00
FaabyyandClaude e03debfd4b Media: engine requests resolve on the editor's slide (#994)
The media.* commands look the shape id up on the editor's current slide, so
the request has to be carried out against that slide too. Passing the show's
slide instead could play another slide's clip under a repeated id.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 06:07:58 +00:00
FaabyyandClaude 906898f662 Canvas: a flipped box snaps the edge under the dragged handle (#961)
The resize handles sit in the box's unflipped frame, so in a flipped box
(also one that a drag past the opposite edge just flipped) the dragged
handle is on the other side. Snapping looked at, and moved, the fixed
edge instead.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 06:07:58 +00:00
Faabyy 2741d3236b Thumbnails: only render the slides in view (#969)
The slide thumbnail pane and the Slide Sorter asked the texture cache for a
thumbnail on every row, in view or not, three or four new ones a frame. The
cache keeps at most 600 thumbnails and drops the 200 oldest beyond that, so
on a deck of more than 600 slides the pane evicted its own thumbnails before
it reached the end: it kept rendering, the rows in view went white again and
again, and it never finished.

Now only rows within the scroll area's clip (plus two rows of margin) ask for
a thumbnail and keep the pane repainting; the others are still laid out, so
scroll height, section headers, clicks and drag-to-reorder are unchanged.
Small decks behave as before.

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


# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto a31b990
# Last command done (1 command done):
#    pick 03fce2a Thumbnails: only render the slides in view (#969)
# No commands remaining.
# You are currently rebasing.
#
# Changes to be committed:
#	modified:   ROADMAP.md
#	modified:   crates/ui-egui/src/huge_deck_tests.rs
#	modified:   crates/ui-egui/src/sorter.rs
#	modified:   crates/ui-egui/src/thumbs.rs
#
2026-10-21 06:07:58 +00:00
FaabyyandClaude 96697f7612 Media: the playing clip decides whether it carries over (#994)
The carry-over check looked the clip up again on the previous slide. If
that slide held the same media under the same id without Play Across
Slides, the voice stopped one slide later. A slide without the id fell
back to the first match in the deck. Use the clip the voice was started
with; the next slide is only checked for another clip under that id.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 06:07:58 +00:00
Faabyy 7b0252f291 Canvas: resizing snaps the edge being dragged (#961)
snap_resize ran snap_rect on the whole box, which looks at all three
edges on each axis and takes the nearest line. A fixed edge already on a
line (distance 0) always won, so the dragged edge never snapped; a fixed
edge near a line moved the dragged edge by its own distance. Resizing now
looks only at the dragged edges (snap_edges), also for the grid fallback.

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


# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto a31b990
# Last command done (1 command done):
#    pick 92df608 Canvas: resizing snaps the edge being dragged (#961)
# Next command to do (1 remaining command):
#    pick abb0e09 Canvas: a flipped box snaps the edge under the dragged handle (#961)
# You are currently rebasing.
#
# Changes to be committed:
#	modified:   ROADMAP.md
#	modified:   crates/engine/src/tests.rs
#	modified:   crates/engine/src/tools.rs
#
2026-10-21 06:07:58 +00:00
FaabyyandClaude 2303efe7d5 Media: stop a clip across slides when the next slide reuses its id (#994)
A clip set to play across slides kept its voice even when the next slide
holds a different clip under the same shape id, so that slide's own clip
never started. Keep the voice only when the next slide has no clip under
that id or the same media.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 06:07:58 +00:00
Faabyy 2d6582163f Media: look a clip up on the slide it belongs to (#994)
The PPTX import numbers shapes per slide, so two slides can hold different
videos under one shape id. find_clip searched the whole deck and took the
first match, so the show's media cues, the keep-across-slides filter and
the media.* requests all got the first slide's clip: from slide 3 on, video 2
played. find_clip now takes the slide the request comes from and searches it
first, then the rest of the deck.

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


# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto a31b990
# Last command done (1 command done):
#    pick 1ab070b Media: look a clip up on the slide it belongs to (#994)
# Next commands to do (3 remaining commands):
#    pick ad4e3f0 Media: stop a clip across slides when the next slide reuses its id (#994)
#    pick 0957b58 Media: the playing clip decides whether it carries over (#994)
# You are currently rebasing.
#
# Changes to be committed:
#	modified:   ROADMAP.md
#	modified:   crates/ui-egui/src/lib.rs
#	modified:   crates/ui-egui/src/media.rs
#	modified:   crates/ui-egui/src/show.rs
#
2026-10-21 06:07:58 +00:00
NetterSilverandClaude Haiku 5.5 e89a893fe1 Add angle adjust handles to partial-circle presets
Fixes #1094

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 05:04:04 +00:00
NetterSilverandClaude Haiku 5.5 72a9e4456e Fix scientific number format for subnormal values
Found while testing crates/model/src/numfmt.rs:204-211 (sci)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 03:34:30 +00:00
NetterSilverandClaude Haiku 5.5 309d9f5b2c Treat CRLF as a single paragraph break in text.set
Found while testing crates/engine/src/cmd/text.rs:1219 (set), via crates/model/src/text.rs:605 (TextBody::from_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:13:45 +00:00
Mex 6b91132ce7 M8: document verified empty-format reliability scope
Record the synthetic formatter regression and preserved General fallback; broader chart gaps and readiness scores remain unchanged.
2026-10-20 23:10:09 +02:00
Mex 5e0a60b15e M8: record empty chart number-format section fix
Record narrow formatter behavior and synthetic regression evidence without changing chart scores or readiness estimates.
2026-10-20 23:09:29 +02:00
Mex 02b3940efd M8: honor explicit empty chart number-format sections
Keep blank whole-format General fallback while hiding values selected by explicit empty sections. Pin section selection, signed zeros, semicolon boundaries and General controls with regressions. Contribution on behalf of @MMexicann.
2026-10-20 23:08:54 +02:00
NetterSilverandClaude Haiku 5.5 98b6d04413 Keep rounded corners round in non-uniformly scaled groups
Fixes #1083

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:50:30 +02:00
Sazid Al Bayazid dc69e57600 Tests: Cancel restores every Options setting, not only the first (#878)
A report on 0.7.0 said the Options dialog's Cancel did nothing, and a
reproduction on 0.8.0 found it working for the appearance toggle and the
name field, without trying the other controls. This covers the rest: the
Editing checkboxes (snap to grid, smart quotes, autocorrect) and the name
field go back with Cancel, like the first checkbox does.

Verified red with the restore of the settings taken out: the name keeps
what was typed.
2026-10-21 00:35:43 +06:00
Bryson Meiling e5a407d6e2 text: add Symbol euro and Apple alternatives; keep covered bullets
Adobe symbol.txt also maps 0xA0 to the euro sign (U+20AC), which
symbol_unicode lacked. Apple's Symbol maps a few codes to different
Unicode than Adobe (0xA4 to U+2044, 0xE1/0xF1 to U+3008/U+3009), so
symbol_font_draw now tries those alternatives after the Adobe character
when the installed face doesn't cover it. The Adobe aliases for
0x44/0x57/0x6D (U+2206/U+2126/U+00B5) are not Apple alternatives and
stay out.

symbol_font_char masked every bullet character to its low byte before
checking direct coverage, so a 'bullet' (U+2022) bullet drew an
unrelated Symbol code. It now maps only symbol-font codes (low byte or
U+F0xx) and falls back to the original character when the face covers
it.
2026-10-20 12:23:50 -06:00
NetterSilverandClaude Haiku 5.5 502fb2ba73 Indent table rows in outline export so they stay on their slide
Found while testing crates/format/src/lib.rs:660-665 (slides_to_outline) and :573-574 (outline_to_slides)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 18:03:20 +00:00
fchinuandClaude Sonnet 5.5 6f83ed464d Format Painter: apply on text selection, keep bullets on partial lines
The painter only applied when a shape was clicked. It now also applies
when a text range is selected by drag, double-click or triple-click.
Paragraph formatting (bullets, alignment) is copied only onto
paragraphs the selection covers whole.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 19:46:15 +02:00
NetterSilverandClaude Haiku 5.5 e37267f548 Strip URI fragment before percent-decoding OPC targets
Found while testing crates/pptx/src/opc.rs:283-285 (resolve)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 18:36:13 +02:00
Brandon ThomasandClaude Opus 5.5 a31b990d32 Skip CodeRabbit reviews on Dependabot PRs (#1068)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:40:09 -04:00
Dylan M. Taylor 7571e01210 Merge pull request #984 from dylanmtaylor/docs/add-sonar-coderabbit-badges
docs: add SonarCloud and CodeRabbit badges to README
2026-10-20 11:33:18 -04:00
NetterSilverandClaude Haiku 5.5 18f5e17b36 Escape carriage return as &#13; in XML text content
Found while testing crates/pptx/src/xml.rs:363 (esc)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 14:54:55 +00:00
NetterSilverandClaude Haiku 5.5 65564cfc1b Reject invalid slide index params instead of using the selection
Found while testing crates/engine/src/cmd/slide.rs:234 (delete); same pattern in index_of :182, move_slide :269, duplicate :193 and section_add :534

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:29:18 +02:00
oracola91 2071ff599d Control server: wait after a failed accept instead of retrying at once
The accepting thread dropped failed accepts with incoming().flatten()
and asked again at once. When accepting keeps failing, for example while
the process has no file descriptors left, that loop keeps a core busy
until it recovers.

Wait 100 ms after a failed accept. The loop goes through a small helper
so the wait can be tested without running out of file descriptors.
2026-10-20 14:49:51 +03:00
oracola91 1df1d2e724 The AppImage and DeckCraft.app ship the licence files
The AppImage step moved usr/share/doc out of the AppDir, so the AppImage
shipped without the licence files the .deb, .rpm and .tar.gz carry from
the same staged tree. DeckCraft.app only got the craft-fonts licences, and
no licence at all when built without CRAFT_FONTS_DIR.

Keep usr/share/doc in the AppDir, and copy the docs into the app bundle
with copy_docs, as the CLI zip does, before signing.
2026-10-20 14:21:42 +03:00
NetterSilverandClaude Haiku 5.5 e843309280 Fix extra empty histogram bin with a fixed bin count
Found while testing crates/model/src/chart.rs:1129-1135 (histogram_bins)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 08:28:08 +00:00
Sazid Al Bayazid 0405fb73bf Charts: a series' own data-label setting decides whether its labels show (#868)
The reader stored a series-level `c:dLbls/c:showVal` in
`Series.data_labels`, but every drawing path tested only the chart-wide
`Chart.data_labels`: a series that asked for labels got none, and a
series that asked for none got them. The series' own setting now wins,
and a bare `<c:showVal/>` (no `val` attribute) means true, as the
schema's default.

The plot group's own setting is respected too: a group that asks for no
labels keeps them off, even when another group of a combo chart asks for
them. Before, any group that asked turned the chart-wide setting on, and
the group that said no inherited it.

Tests: `crates/pptx/tests/chart_data_labels.rs` - a bare series
`showVal` shows that series' labels while its siblings stay quiet; an
explicit `val="0"` on the first series keeps it quiet under a chart-wide
on; a combo chart keeps each group's own setting; all three settings
round-trip. Red-proof: with the series-level setting ignored, the first
two fail; with the group's setting ignored, the combo chart's bar group
shows labels it asked to keep quiet.

The model gains `Chart::series_data_labels`, the one place that decides
whether a series' labels show; the render paths (bar, column, line,
area, pie, doughnut, radar, scatter) call it.
2026-10-20 09:31:04 +06:00
Sazid Al Bayazid 0b476e823d Tests: the Options dialog's Cancel, Esc and the close button restore the settings (#878)
The Options dialog applies every change as it is made, and closing it
with Cancel, Esc or the window's close button puts the settings back the
way they were. Nothing covered that, so a change to the dialog could have
dropped it unnoticed.

The kittest now covers all three ways out: a checkbox put back by Cancel,
the appearance and the interface size put back by Esc, the same checkbox
put back by the window's close button, and the dialog closing in each
case. Red-proof: with the Cancel restore removed the checkbox test fails
(the setting stays changed), with the Esc restore removed the appearance
test fails (it stays dark), and with the Esc/close restore removed the
close-button test fails (the setting stays changed).

ROADMAP progress-log row added.
2026-10-20 09:21:26 +06:00
Sazid Al Bayazid 52a9a45f6a Render: a quick style on an empty placeholder is covered by a test (#832)
The fix changed the empty-placeholder fill gate, but nothing locked the
behaviour in: a quick style's fill has to appear on an empty placeholder,
and a placeholder with no style at all still has to stay empty.

The new render test draws an empty body placeholder in the editor with a
quick style and checks that the accent fill is painted - every channel of
it - and that the same placeholder without a style stays white. With the
gate reverted to `s.fill.is_none()` alone the first assertion fails - the
placeholder stays white - so the test is red on the unfixed code.
2026-10-20 09:09:28 +06:00
Sazid Al Bayazid bc12031d70 Canvas: the resize cursor follows the handle's diagonal, whatever the shape (#745)
Two gaps in the resize cursor, both found by a review of the fix:

- The handle's direction was taken from the box centre to the handle, so a
  wide rectangle's corner pointed almost along an edge: a 400x80 shape's NW
  corner showed a horizontal cursor. A corner drags along the diagonal
  whatever the shape's proportions, so the direction is now normalised -
  the diagonal at 45 degrees - and only the shape's rotation and flips
  turn it.
- The cursor read the shape's own box, but the handles are drawn from the
  effective one, which a placeholder inherits from its layout. Such a
  placeholder showed the default arrow instead of a resize cursor.

The tests cover both: a wide shape keeps its corner cursors diagonal, and
a placeholder that inherits its box has a resize cursor. Red-proof: with
the aspect ratio back in the direction, the wide shape's NW corner shows
a horizontal cursor; with the shape's own box, the placeholder shows the
default arrow.
2026-10-20 08:33:22 +06:00
Brandon ThomasandClaude Opus 5.5 fe510bdac6 Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:18:44 -04:00
Eduardo GarciaandClaude Sonnet 5.5 a9031d63e1 Test: resolve attribute prefixes in the namespace check (#974)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 17:12:53 -03:00
Zetenypapp 0dc39b9efc Merge remote-tracking branch 'origin/main' into fix/smart-duplicate-966
# Conflicts:
#	ROADMAP.md
2026-10-19 21:33:38 +02:00
Zetenypapp 57d65faf4f Merge remote-tracking branch 'origin/main' into fix/find-focus-951
# Conflicts:
#	ROADMAP.md
2026-10-19 21:33:38 +02:00
Hamza 7040bb0619 Control server: cap the polite drain at the grace deadline
set_read_timeout applies to each read, so a peer that sent a byte just
before every timeout expired could hold the connection thread, and its
slot, for roughly twice the grace. Set the remaining time before each
read instead.
2026-10-20 00:28:41 +05:00
Hamza 900f62f5a3 Control server: a rejected line's reason survives a pipelined peer
A client that sent another line while its previous one was being rejected never
saw the reason. `serve` writes the error and drops the socket; the client's
second write lands on a socket the server has already closed, the kernel answers
it with RST, and an RST discards what was queued for the client -- including the
reply. The client reads a connection reset instead of being told why.

The comment there, "a peer that can't read the reason loses nothing", holds for
a peer that stopped reading. A peer that is reading loses the reason, and it is
the one that gets reset.

`close_politely` shuts the write side down so the FIN follows the reply, then
reads and discards for up to the write timeout and at most 64 KiB, so the peer's
pipelined line is absorbed instead of answered with RST. Both paths that reject
and close use it: a line that is not a request, and a line over the length
limit. A peer that keeps talking is still dropped at the deadline.

This is also why `a_line_that_is_not_a_request_closes_the_connection` was red
about four times in five: it writes a second line after the junk one. Run alone
twelve times, it failed 10 on `main`, 0 with the second line removed, and 0 with
this change and the second line kept.
2026-10-20 00:28:41 +05:00
Hamza 4b474d77a9 Replace Fonts test: a window wide enough to hold Home's ribbon
Home's ribbon has grown since the test was written. At the harness's
1400x900 the Editing group starts at x=1398, so the Replace Fonts
button is two pixels wide on screen and a click at its centre lands
outside the window: the button is found and enabled, but never clicked,
and the dialog assertion fails. The ribbon does not scroll sideways, so
the window has to hold it; 1800x1100 is what the other full-ribbon
tests here use.

cargo xtask ci fails at `test` on main without this.
2026-10-20 00:28:19 +05:00
Sazid Al Bayazid 756ca23d31 Canvas: the Format and Animation Painter override the cursor (#830)
When the Format Painter or Animation Painter was armed, the mouse cursor
stayed as the default arrow. The user had no visual indication that the
tool was active and waiting for a click on a shape to apply the copied
format.

The canvas now sets the Copy cursor while either painter is armed, and
goes back to its usual cursor once the tool is done. The override takes
precedence over the other cursor assignments (resize, move, crosshair,
text), because the painter's whole purpose at that moment is to receive
the next click.

The kittest covers both painters: it hovers over the canvas, checks the
cursor is not the Copy one yet, arms the painter and checks it is. With
the painter override removed both fail - the cursor stays Move instead
of Copy.

ROADMAP progress-log row added.
2026-10-20 01:04:45 +06:00
Bryson Meiling f8c8223ff0 Merge origin/main into feat/symbol-font-mac-remap 2026-10-19 13:02:38 -06:00
Bryson Meiling 883cc6e391 text: drop ASCII < = > ? remaps and draw them from Symbol directly
0x3C-0x3F are plain ASCII < = > ? in Adobe's symbol encoding (<= >= inf
sit at 0xA3/0xB3/0xA5 and therefore at 0x5C), so 'a<b' set in Symbol
drew 'a<=b' on macOS. Drop those four symbol_unicode arms and their
pairs in the whole-table test, and list 0x3C-0x3F as ASCII.

symbol_font_draw returned None for codes with no table entry, so U+F031
('1'), U+F028 ('(') etc. still didn't draw from Symbol. For 0x20-0x7E
with no table entry, fall back to the ASCII character itself when the
face covers it; covers_symbol now delegates to symbol_font_draw so the
shaping path picks the Symbol face for those codes too.

ROADMAP header keeps a single Last reviewed line (replaces, not adds).
2026-10-19 12:58:20 -06:00
Sazid Al Bayazid 37d69f0449 Tests: Ctrl+A in the slide sorter selects every slide (#936)
The sorter's Ctrl+A runs edit.selectAll with the slides scope, so every
slide in the deck is selected, as in PowerPoint. Nothing covered the key
press itself, so a change to the shortcut could have dropped the scope.

The kittest covers the whole way: the key press, the scope and the
selection it leaves behind. Red-proof: with the sorter's Ctrl+A no longer
asking for the slides scope, the selection stays at the one current slide
and the test fails.

ROADMAP progress-log row added.
2026-10-20 00:55:56 +06:00
Sazid Al Bayazid 8e83e8bb81 Render: quick styles render on empty placeholder text boxes (#832)
The render gate for an empty placeholder's fill checked only
s.fill.is_none(). A quick style that sets a style reference without
an explicit fill left s.fill as None, so the gate skipped the fill
even though the shape had been formatted — the style only appeared
after text was added.

The gate now also checks s.line and s.style: if any of the three has
a local override, the placeholder is considered formatted and the
fill is drawn. A truly unformatted empty placeholder still shows the
dashed outline and prompt.
2026-10-20 00:17:15 +06:00
Sazid Al Bayazid 3610cd6b6f Thumbnails: navigating slides scrolls the pane to keep the current slide visible (#917)
The pane scrolled only by hand: past a screenful of slides, arrow-key
navigation left the current thumbnail out of view. When the slide changes,
the pane now scrolls it into view - minimally, only once the thumbnail has
left the viewport, as in PowerPoint, so a visible thumbnail never moves the
pane and the user's own scrolling stands. The pane's scroll offset is
recorded in UiState (skipped by serde) for the control channel and tests.
2026-10-20 00:16:40 +06:00
Mexandskylar taylor-barrick f34f4121c8 Format: retain imported chart parts through native saves (#851)
* M2.1: preserve chart parts through native saves

Serialize retained chart relationships and payloads in native files.
Add native and headless file-command round-trip regressions.

Contribution on behalf of @MMexicann.

* Format: bound opaque chart parts as native ZIP entries

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* Model: track transient native chart part entries

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* PPTX: initialize native chart entry metadata

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* Engine: adapt chart native roundtrip fixtures

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* Docs: describe bounded chart sidecars and validation

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* Roadmap: record bounded chart persistence evidence

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

---------

Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
2026-10-19 11:44:16 -06:00
Fabian ba194e35d8 fix: DeckCraft upstream #955
README: the Linux runtime libraries a tarball or musl install has to provide, with the Alpine and Void package names and what each one is for.
2026-10-19 16:57:48 +02:00
NetterSilverandClaude Haiku 5.5 1090ca2d70 Apply trailing-comma thousands scaling to formats with decimals
Found while testing crates/model/src/numfmt.rs:256 (fixed)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:55:10 +00:00
NetterSilverandClaude Haiku 5.5 9412d3ccaf Format General scientific notation like Excel (1.5E+12)
Found while testing crates/model/src/numfmt.rs:115-116 (general)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 12:09:27 +00:00
NetterSilverandClaude Haiku 5.5 c90ea7d9ae Reject non-hex characters such as '+' in Rgba::from_hex
Found while testing crates/color/src/lib.rs:41

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 05:34:48 +00:00
NetterSilverandClaude Haiku 5.5 5beca8c1b6 Play animated GIFs inside groups
Fixes #1020

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 05:21:55 +00:00
MarekSpodBiedry 5ac14f2015 Test: modify the slide through Arc::make_mut (#1005) 2026-10-19 00:44:03 +02:00
MarekSpodBiedry 9b56f83929 Show: draw shapes stacked above a playing video over its frame (#1005) 2026-10-19 00:37:51 +02:00
MarekSpodBiedry 935724850b Insert: keep the Icons popup open when clicking the search box (#971) 2026-10-18 23:39:50 +02:00
MarekSpodBiedry a49570e45b Keep the crop of a PPTX video and apply it in the poster and playback (#972) 2026-10-18 23:38:11 +02:00
NetterSilverandClaude Haiku 5.5 b75ea712e9 Count line breaks as one char in Find/Replace
Found while testing crates/engine/src/cmd/find.rs:107-121 (para_chars / para_matches; splice at :124)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:53:53 +02:00
Dylan M. Taylor fc8e811c2a docs: put SonarCloud and CodeRabbit badges on their own line 2026-10-18 14:47:35 -04:00
Dylan M. Taylor 010941ec60 ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
2026-10-18 12:31:16 -06:00
FaabyyandClaude Sonnet 5.5 8bc3d3f110 Text boxes: a click on the frame selects the box whole (#787)
A click on the frame band of an unselected or selected text box or
placeholder started text editing, so the box could not be selected,
moved, duplicated or deleted from its frame. The frame band now selects
the box whole, like PowerPoint; only a click inside takes the caret.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:25:14 +00:00
NetterSilverandClaude Haiku 5.5 e34169ff75 Clear animation triggers that point at deleted shapes
Found while testing crates/engine/src/cmd/edit.rs:573-585 (delete)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:16:01 +02:00
ZetenypappandClaude Opus 5.5 86e7879f5e Duplicate: Undo and Redo forget the last Duplicate's move
A shape added after an Undo can get an undone copy's id; duplicating it then
used the move measured from the old original.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:52:41 +02:00
Faabyy a7833e094d #807: Browse for Themes brings a template's slide master and layouts
Design › Browse for Themes… with a .pptx or .potx now does what PowerPoint
does: the file's first slide master and its layouts replace the master the
current slide uses (a preserved master stays beside it), stretched to this
deck's slide size. Each slide on the old master moves to the new layout with
the same name, else the same kind, and keeps its content as Layout does. One
undo step. A .thmx still brings its theme only.

Reuse Slides' master copy (media, fresh master, layout and shape ids) moves
into reuse::import_master so both commands share it.

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



# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto 22db926
# Last command done (1 command done):
#    pick 8992623 #807: Browse for Themes brings a template's slide master and layouts
# No commands remaining.
# You are currently rebasing.
#
# Changes to be committed:
#	modified:   ROADMAP.md
#	modified:   crates/engine/src/cmd/design.rs
#	modified:   crates/engine/src/cmd/reuse.rs
#	modified:   docs/file-format-parity.md
#
2026-10-18 17:46:44 +00:00
Kushagra Kumar 0a0ad04f47 fix(design): ensure Apply to All clears layout overrides when slides and masters match 2026-10-18 23:11:24 +05:30
Kushagra Kumar 74bc27f27e fix(design): preserve master and layout backgrounds on reset and copy (#891) 2026-10-18 22:28:15 +05:30
MarekSpodBiedry ea98ebb615 Drop a clippy allow the workspace already sets 2026-10-18 18:47:14 +02:00
MarekSpodBiedry 85b994e7cd Hide the slide show media controls when the pointer is idle (#967) 2026-10-18 18:42:39 +02:00
MarekSpodBiedry 445e7c6b55 About credits test: clear textures_delta, drive the real dialog tabs; ROADMAP progress-log row for #843 2026-10-18 18:39:06 +02:00
MarekSpodBiedry 0298201b2c Merge remote-tracking branch 'origin/main' into review-906 2026-10-18 18:37:41 +02:00
NetterSilverandClaude Haiku 5.5 03993057a4 fix: decode &nbsp; to a non-breaking space so blank paragraphs survive trimming
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:01:49 +02:00
ZetenypappandClaude Opus 5.5 172952f6e0 Find: "Find what:" has the keyboard when the dialog opens (#951)
Ctrl+F and typing searches without clicking the field first, and the field
keeps the keyboard after Enter finds a match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:00:41 +02:00
NetterSilverandClaude Haiku 5.5 e54bd2ccb7 Read a newline in pasted HTML text as a space
Found while testing crates/engine/src/cmd/richclip.rs:257-283 (decode_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:57:05 +02:00
ZetenypappandClaude Opus 5.5 8e9c198c7c Duplicate repeats the move of the last copy, as in PowerPoint (#966)
Duplicating a shape, moving the copy and duplicating that copy again places
the next copy the same distance further on. Anything else, an undone copy or
another slide gets the usual offset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:47:04 +02:00
Dylan M. Taylor cb78440a16 Merge pull request #952 from dylanmtaylor/chore/drop-competitor-references
docs: drop references to competing commercial software
2026-10-18 11:32:28 -04:00
Eduardo GarciaandClaude Sonnet 5.5 601823d98c Declare the a: and r: namespaces on the modern comments part (#974)
p188:cmLst declared only xmlns:p188 but its comment bodies use a:bodyPr, a:p, a:r and
a:t, so the part was not namespace-well-formed and PowerPoint refused the saved file.
A regression test parses every written XML part with a namespace-aware reader.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:22:07 -03:00
Brandon Thomas 868016ee2f Merge pull request #882 from marcoarnulfo/fix/show-anim-steps 2026-10-18 11:21:27 -04:00
Brandon Thomas 0981e6e675 Merge pull request #871 from marcoarnulfo/feat/alignto 2026-10-18 11:21:26 -04:00
Brandon Thomas 006bbe1136 Merge pull request #864 from marcoarnulfo/feat/ppsx-starts-show 2026-10-18 11:21:26 -04:00
Brandon Thomas dfe40a17bf Merge pull request #856 from marcoarnulfo/feat/autoformat-ordinals 2026-10-18 11:18:18 -04:00
Brandon Thomas 0b6705c12c Merge pull request #943 from Zetenypapp/fix/click-in-front-931
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:18:18 -04:00
Brandon Thomas ef6e8cdc2f Merge pull request #946 from Zetenypapp/fix/edit-points-undo
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:18:17 -04:00
Brandon Thomas 9430673350 Merge pull request #886 from Silvertree2010/fix/h2169-shape-setbounds-shape-resize-acc
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:18:16 -04:00
Brandon ThomasandClaude Opus 5.5 efc279d6f2 Clamp overflowing geometry to the limit and keep rotated resizes in range
An overflow to infinity (move x=1e308 dx=1e308) now lands on the limit
instead of resetting to 0, and a rotated shape re-centred after a capped
resize stays within POS_LIMIT (CodeRabbit review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:18:16 -04:00
Brandon Thomas c1184d64fd Merge pull request #902 from Silvertree2010/fix/h2405-comment-delete-with-an-invalid-i 2026-10-18 11:17:29 -04:00
Brandon Thomas 8b01ed3e88 Merge pull request #901 from Silvertree2010/fix/h2367-flip-horizontal-flip-vertical-on
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:17:29 -04:00
Brandon Thomas 9e1430e296 Merge pull request #898 from Silvertree2010/fix/h2311-change-case-capitalize-each-word
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:17:28 -04:00
Brandon Thomas fab77f45e5 Merge pull request #979 from MarekSpodBiedry/issue-968 2026-10-18 11:17:20 -04:00
Brandon Thomas 11bc157477 Merge pull request #928 from FabPei/agent/issue-845 2026-10-18 11:17:19 -04:00
Brandon Thomas 389c2c2921 Merge pull request #885 from switch87/feat/784-transparent-png-export 2026-10-18 11:17:19 -04:00
Brandon Thomas 4fcf593bac Merge pull request #948 from Zetenypapp/fix/duplicate-shape-ids
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:17:06 -04:00
Brandon Thomas 26371b0629 Merge pull request #947 from Zetenypapp/fix/huge-effect-sizes
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:17:06 -04:00
Brandon Thomas dfa91441e6 Merge pull request #802 from Faabyy/import-warnings-203
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-18 11:17:05 -04:00
Brandon Thomas 9eadf35c97 Merge pull request #893 from Silvertree2010/fix/h2209-reuse-slides-slide-reuse-leaves 2026-10-18 11:16:19 -04:00
Brandon Thomas f5a895ef75 Merge pull request #827 from Silvertree2010/fix/h1748-slide-fromoutline-insert-slides 2026-10-18 11:16:18 -04:00
Brandon Thomas 0883d89e77 Merge pull request #796 from Silvertree2010/fix/h1431-outline-export-writes-a-multi-pa 2026-10-18 11:16:18 -04:00
Brandon Thomas eb417f0470 Merge pull request #795 from Silvertree2010/fix/h814-html-paste-drops-text-from-a-bar 2026-10-18 11:16:17 -04:00
Dylan M. Taylor 483a351c37 docs: add SonarCloud and CodeRabbit badges to README 2026-10-18 11:01:49 -04:00
Dylan M. Taylor 19fadde1cc docs: clarify that feature percentage is measured against tracked features in docs/target-app-parity.md 2026-10-18 10:47:26 -04:00
Alexey 46dec4a660 fix(wasm): avoid std services that panic on wasm32-unknown-unknown at startup
recovery::instance_id() mixed std::process::id() into the instance hash —
on wasm32-unknown-unknown that panics (``no pids on this platform``, and
SystemTime is missing too), so the web app died before its first frame.
On wasm the browser clock (js_sys::Date::now(), already an engine dep for
comment timestamps) stands in.
2026-10-18 15:27:59 +02:00
NetterSilverandClaude Haiku 5.5 45eeb7c819 Add slides from an outline to the section of the slide they follow
Found while testing crates/engine/src/cmd/slide.rs:~393 (from_outline), crates/format/src/lib.rs:225 (add_outline_slides)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:18:49 +00:00
MarekSpodBiedry 6dca3e860b Hide the idle cursor over clickable clips during a slideshow (#968) 2026-10-18 13:53:14 +02:00
NetterSilverandClaude Haiku 5.5 3e729bbf08 Limit shape.setBounds, shape.resize and shape.move size and position
Found while testing crates/engine/src/cmd/shape.rs:215 (set_bounds; shape.resize at :~183 and shape.move at :~171 have the same problem)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 10:19:53 +00:00
NetterSilverandClaude Haiku 5.5 3f973b0d87 Mirror the rotation when flipping a rotated shape
Found while testing crates/engine/src/cmd/shape.rs:400-412 (flip; used by arrange.flipHorizontal / arrange.flipVertical)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:17:00 +02:00
NetterSilverandClaude Haiku 5.5 e5a3c9ca65 Change case across formatting run boundaries
Found while testing crates/engine/src/cmd/format.rs:693-715 (also the whole-shape path at :734-741)

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>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:13:47 +02:00
ZetenypappandClaude Opus 5.5 8ee5094c60 Open: also cap effect sizes in inherited text styles and theme effects
Review follow-up. A huge glow or shadow could still arrive through what text and shapes inherit:
the deck's default text style, a master's title, body and other styles, a text box's or table
cell's own list style, and the theme's effect styles that shape styles point at. sane_numbers caps
those too; the test plants 10^18 pt glows in each and checks they come back capped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:41:36 +02:00
Faabyy 368e07493c PPTX: a slide that can't be read is reported, not dropped in silence (#203)
The importer skipped a slide whose part was missing or unreadable and only
logged it, so `info` showed one slide fewer with exit code 0 and a save wrote
the shorter deck over the original.

`deckcraft_pptx::import_checked` returns the parts it left out (`import` is
unchanged). The engine carries them as `Damage::slides` next to the damaged
media of a `.deckcraft` file: `file.open` and `file.openBytes` return
`lostSlides`, the app shows a message and opens a repaired copy (so Save asks
for a new name), and `deckcraft-cli` warns on stderr.

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


# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto 1dc6972
# Last command done (1 command done):
#    pick d2e995c PPTX: a slide that can't be read is reported, not dropped in silence (#203)
# No commands remaining.
# You are currently rebasing.
#
# Changes to be committed:
#	modified:   Cargo.lock
#	modified:   ROADMAP.md
#	modified:   apps/deckcraft-cli/Cargo.toml
#	modified:   apps/deckcraft-cli/src/main.rs
#	new file:   apps/deckcraft-cli/tests/damaged.rs
#	modified:   crates/engine/src/cmd/file.rs
#	modified:   crates/engine/src/tests.rs
#	modified:   crates/pptx/src/lib.rs
#	modified:   crates/pptx/src/read/mod.rs
#	modified:   crates/pptx/tests/malformed.rs
#	modified:   docs/file-format-parity.md
#


# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto 22db926
# Last command done (1 command done):
#    pick 31b0d33 PPTX: a slide that can't be read is reported, not dropped in silence (#203)
# No commands remaining.
# You are currently rebasing.
#
# Changes to be committed:
#	modified:   Cargo.lock
#	modified:   ROADMAP.md
#	modified:   apps/deckcraft-cli/Cargo.toml
#	modified:   apps/deckcraft-cli/src/main.rs
#	new file:   apps/deckcraft-cli/tests/damaged.rs
#	modified:   crates/engine/src/cmd/file.rs
#	modified:   crates/engine/src/tests.rs
#	modified:   crates/pptx/src/lib.rs
#	modified:   crates/pptx/src/read/mod.rs
#	modified:   crates/pptx/tests/malformed.rs
#	modified:   docs/file-format-parity.md
#
2026-10-18 09:18:46 +00:00
ZetenypappandClaude Opus 5.5 051d3b4fd3 Canvas: shapes in front of grouped text and of the edited box's edge take the click too
Review follow-up. The in-front check now also looks among the members of the group that holds
the edited text, and the edited box's frame (which drags it) yields to a shape in front of the
text at that spot; the box's own handles stay on top, as in PowerPoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:56:38 +02:00
ZetenypappandClaude Opus 5.5 daae74b57e Open: cap absurd effect sizes from a file at what Format Shape allows
A .pptx or .deckcraft could give a glow, soft edge, shadow or reflection any size. A glow of
~10^16 pt made vello panic while flattening, one of 10^18 pt made rendering ask for 34 GB and abort.
sane_numbers (run by repair on every opened file) now caps them like line widths and font sizes:
glow 150, soft edges 100, shadows 200, reflections 100 pt; shadow scale and skew are kept sane and
non-numbers become 0, on shapes and on text runs, table cells included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:07:58 +02:00
ZetenypappandClaude Opus 5.5 5e8c1bcc51 Open: give shapes that repeat an id in a .deckcraft file fresh ones
repair re-id'd repeated slide ids but not shape ids, so such a file opened as a deck that fails
validate, and edits by id found only the first of the shapes sharing one. Repeats on a slide,
layout or master (groups included) now get fresh ids from the deck's counter; the first keeps
its id, and decks without repeats aren't copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:04:22 +02:00
ZetenypappandClaude Opus 5.5 f4afe6d2df Edit Points: turning a shape into a freeform is one undo step
shape.editPoints was a noundo command, so the conversion from a preset to custom geometry was
never recorded and Ctrl+Z took back the step before it. It is undoable now (only an actual change
records a step), and Edit Points ends when Undo turns the shape back into a preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:01:46 +02:00
ZetenypappandClaude Opus 5.5 a773fcfce9 Canvas: a shape in front of edited text takes the click (#931)
While a placeholder's text was being edited, place_caret took every click inside the text box
before the hit test, so a shape in front of it couldn't be selected. It now leaves the click to
the hit test when a shape above the one holding the text is under the pointer; a click on the
text where nothing is in front still moves the caret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:59:58 +02:00
Dylan M. Taylor 121882e177 docs: drop references to competing commercial software 2026-10-18 01:20:12 -04:00
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
Fabian 887e868a46 fix: DeckCraft upstream #845
PDF export rasterized every slide as one full-page bitmap, so the
background, master and layout graphics a deck repeats were stored once
per slide. Draw each slide as its backdrop plus the slide's own shapes;
equal backdrops become equal images and the writer stores each once.
2026-10-17 15:52:54 +02:00
Hamza afd6d3a3eb Header & Footer: the automatic date takes a format
`HeaderFooter::date_format` is documented as the `datetime1`…`datetime13`
format for automatic dates, and nothing read it: grep found one hit, the field's
own declaration. The Date placeholder was hardcoded to `datetime1`, so the
footer date was always the short date, and a `dateFormat` parameter was accepted
and silently dropped — the command returned Ok, so a caller could not tell.

Everything else was already there: `datetime::field_text` renders all thirteen
types and Insert ▸ Date & Time already offers them. Missing was the wiring from
a choice to the placeholder.

`design.headerFooter` now takes `dateFormat`, refuses anything outside the
thirteen rather than ignoring it, stores it, creates the placeholder with that
field type, and updates an existing one when the format changes — as it already
did for a changed `dateText`. A fixed date still wins as plain text, and going
back to automatic restores the remembered format.

`datetime::DATE_KINDS` is the canonical list; `dialogs.rs` uses it rather than
its own copy of the same thirteen strings, and the Header & Footer dialog gains
a Format dropdown listing each type by what it would show now, enabled while the
date is automatic.

Not here: reading a deck does not populate `date_format`, so the dialog shows
datetime1 preselected for a deck whose dates are something else until the user
picks. That is a pptx read-side change and wants its own PR.
2026-10-17 17:17:37 +05:00
Mex 2a4adf3024 Merge branch 'main' into fix/pptx-trigger-click-steps 2026-10-17 13:29:55 +02:00
Lexx143andClaude Fable 5.1 9632162029 macOS: offer .potx and .ppsx too, keep the desktops' file types equal, log the change
Windows (deckcraft.wxs) and Linux (the desktop file) already offer DeckCraft for .deckcraft,
.pptx, .potx and .ppsx; the macOS Info.plist now declares the same four, so a template or a
slide show opens from Finder as well. A test in apps/deckcraft reads the three packaging files
and fails when they drift apart, and checks every document type names its content type (Finder's
"Change All" needs it). Progress log row for #138 and #347.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-17 12:17:45 +05:00
cohenmendyandClaude Opus 5.5 12085b1153 fix(macos): open .pptx and .deckcraft files from Finder
The macOS bundle declared the .deckcraft and .pptx types but listed no
CFBundleDocumentTypes, so DeckCraft never appeared under Open With and
couldn't be made the default app for presentations. Finder opens also
arrive as a kAEOpenDocuments Apple event, not argv, which winit doesn't
handle, so a double-clicked file wouldn't have loaded anyway.

- Info.plist.in: document types for DeckCraft Presentation (Owner) and
  PowerPoint Presentation (Alternate, so PowerPoint/Keynote stay
  preferred unless the user picks DeckCraft).
- apple_events.rs: register an open-documents handler with the pinned
  fmv-macos-events crate GridCraft already uses, and open the paths each
  frame; failures show in the status bar.
- Ignore the -psn_ argument older macOS passes on a Finder launch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxAm8VMKHa5w6kwkjBaT3D
2026-10-17 12:17:45 +05:00
MarekSpodBiedry b0006d6af8 About: Contributors and Models get their own fixed-size block below the tabs (#843) 2026-10-17 07:46:18 +02:00
NetterSilverandClaude Haiku 5.5 a55b6cfa6f Clamp text.selectParagraph paragraph index to avoid overflow
Found while testing crates/engine/src/cmd/text.rs:1151 (select_para)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 05:46:04 +02:00
NetterSilverandClaude Haiku 5.5 70244c899d Reject invalid comment.delete index instead of clearing the slide
Found while testing crates/engine/src/cmd/review.rs:140-155 (delete)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 05:19:50 +02:00
Bryson Meiling 8f4b0d4c3f text: test the whole Adobe Symbol encoding table
The symbol_unicode table has 130 entries; only U+F0DE was covered by a test.
Add symbol_unicode_table_covers_the_adobe_encoding, which asserts every
code->real-Unicode mapping directly against the authoritative
unicode.org/MAPPINGS/VENDORS/ADOBE/symbol.txt table, plus that ASCII
pass-through and CUS fragment codes return None. It is pure and
platform-independent (no fonts involved), so it runs on every machine and
catches any typo in the table.
2026-10-16 14:50:09 -06:00
Bryson Meiling 271c6c4037 text: remap symbol-font U+F0xx codes to the installed Symbol face's real Unicode
PowerPoint stores a Symbol-font character as U+F0xx with <a:sym typeface="Symbol"/>:
U+F0DE is the Symbol font's double arrow (=>). The layout drew it from whichever
installed face happened to map U+F0DE (Wingdings draws an arrow at another angle),
because Apple's "Symbol" on macOS has no private-use cmap at all — it maps the
glyphs to real Unicode instead. The test that checked this skipped on macOS.

Add the Adobe Symbol encoding table (unicode.org MAPPINGS/VENDORS/ADOBE/symbol.txt;
Apple's Mac OS Symbol is laid out identically) and remap a document's U+F0xx code to
the real Unicode character the installed Symbol face draws it at: the PUA code when
the face has one, else the encoding's real character (U+F0DE -> U+21D2 =>).

The remap applies wherever a symbol-font character is drawn:
- text runs with an a:sym font (shape_cells picks the face, shape_item remaps during shaping),
- bullets (symbol_font_char),
- metafile text (symbol_char).

The previously-skipped symbol_font_characters_draw_in_the_sym_font test now runs on
macOS (its guard checks the remapped character), and a new test checks U+F0DE draws
as U+21D2 on Apple's Symbol.
2026-10-16 14:46:07 -06:00
Kushagra Kumar 432016fc96 Propagate Format Background Apply to All to slide masters (#891) 2026-10-17 00:06:26 +05:30
NetterSilverandClaude Haiku 5.5 d3fb412167 Remap slide links to the copies when reusing slides
Found while testing crates/engine/src/cmd/reuse.rs:~150 (reuse; slides get fresh ids but Action::Slide targets are never remapped)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:33:41 +00:00
Nikita RakitinandCursor beb883d036 Files: save and open outlines as RTF
Adds PowerPoint's Outline/RTF save type: slide titles at outline level 0
(bold, larger), body paragraphs indented at their outline level, bold,
italic and underline kept, non-ASCII as \u escapes. RTF outlines (ours,
PowerPoint's or a word processor's, by outline level or indent) open as
a presentation or drop in as slides.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 20:34:44 +03: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
Gert Pellin 392734a135 Export: transparent background docs and clearer refusal (#784)
The error for a non-PNG export says the option is for PNG export. ROADMAP,
file-format, UI parity and control protocol docs mention the option.
2026-10-16 16:42:42 +02:00
Gert Pellin 39e7fb1d4f Export: PNG with a transparent background (#784)
The renderer lays every slide background over white (as PowerPoint does),
so a PNG export was always opaque. A new transparentBackground option leaves
the slide background out (both the white underlay and the background fill),
so only what is on the slide is drawn over transparency. Shapes filled with
the background (useBgFill) still show it.

- render: RenderOpts::transparent_background
- engine: file.export {transparentBackground} (PNG only; other formats
  refuse it and write nothing), file.render {transparentBackground}
- UI: Transparent background checkbox in the Export dialog (PNG) and in
  File > Export > Change File Type > PNG; the web download honours it
- CLI: render --transparent
2026-10-16 16:30:52 +02:00
Marco Arnulfo 5f0a088870 PPTX: paragraph builds edited in PowerPoint keep their clicks (part of #844) 2026-10-16 16:19:13 +02:00
Marco Arnulfo c7423aa986 Show: Back leaves a slide whose first animation plays by itself (part of #844) 2026-10-16 15:47:21 +02:00
midasmatterhorn 013941e8e8 engine: a hostile text selection can't drive paragraph commands past the body
Paragraph commands (align, case, insert, paste, selectParagraph) iterated
for pi in a.0..=b.0 with both ends taken verbatim from text.select, so a
control-channel selection with caret u64::MAX spun the loop over 2^64
entries on the UI thread, and text.selectParagraph's at.0 + 1 overflow-
panicked in debug builds. Clamp every range at the body's last paragraph
and use saturating_add for the single-index arithmetic.

Regression test: paragraph_commands_clamp_a_hostile_text_selection —
fails on main with 'attempt to add with overflow' at cmd/text.rs:1151,
passes with the clamp.
2026-10-16 15:12:10 +03:00
Marco Arnulfo 8c41140abe Arrange: Align to Slide and Align Selected Objects in the Align menu, as in PowerPoint 2026-10-16 07:47:16 +02:00
NetterSilverandClaude Haiku 5.5 b6af19ae35 Keep multi-paragraph slide titles on one line in outline export
Found while testing crates/format/src/lib.rs:490

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>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 07:15:31 +02: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
Dylan M. Taylor 6d98534a27 ci: switch to shared nix workflow from craft-common-actions 2026-10-15 23:57:56 -04:00
Marco Arnulfo e755d3eeb1 Opening a .ppsx from the command line starts the slide show (#862) 2026-10-16 05:44:02 +02: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
Marco Arnulfo 367c3488c5 Text: AutoFormat superscripts ordinals (1st, 22nd) as you type 2026-10-16 02:06:42 +02:00
Marco Arnulfo d28b4e0f54 Review: reject revisions after Compare, one or all
review.reject {index | all} drops revisions from the list without applying
them, so the open deck stays as it is: a rejected inserted slide is never
added, a rejected deleted slide stays, a rejected changed slide keeps its
current content. Accept All skips what was rejected. Rejections are kept on
the compare session (keyed by the open slide id and the revised slide) and
end with End Review; like Compare and End Review, rejecting is not an undo
step, since it leaves the document untouched.

Revisions pane: Reject next to each Accept, and Reject All.
2026-10-15 22:54:04 +02: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
Marco Arnulfo 13e06cace2 Shapes: Edit Points — Straight/Curved Segment and Open/Close Path from the right-click menu 2026-10-15 20:04:58 +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
Marco Arnulfo b27bd0519c Preferences: checkboxes and slider rails show in light mode (fixes #766) 2026-10-15 11:53:33 +02: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
NetterSilverandClaude Haiku 5.5 c39d71a51a Keep bare '&' text when pasting HTML instead of dropping it
Found while testing crates/engine/src/cmd/richclip.rs:257-283 (decode_text)

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 08:34:39 +00:00
debarghaya b919e33217 UI: close About dialog on Cancel and test Options Cancel from File tab (#782)
In release 0.7.0, Cancel in the Preferences dialog (opened via Options
in the File tab) did nothing. This was previously addressed on main by
#757 (#752).

This change adds an egui_kittest regression test verifying that clicking
Options from the File tab opens the Preferences dialog and clicking
Cancel closes it while keeping Backstage open. In addition, the About
dialog's Cancel button now properly closes the dialog instead of being
ignored.

Closes #782.
2026-10-15 13:24:39 +05:30
Hamza 79088b7060 Text: an empty line takes the size of the mark that ends it (#780)
An empty line inside a paragraph -- between two `a:br`, or after a trailing one
-- was laid out at the size of the paragraph's *first run*. PowerPoint sizes it
by the mark that closes the line: the `a:rPr` of the `a:br` that ends it, or
`a:endParaRPr` for the paragraph's last line. Every line below then sits too
high or too low, by the difference; in the reported poster deck the text drifted
by up to 173pt.

`shape_para` resolved one `lead_size`/`lead_face` per paragraph and
`layout_scaled` used it for every line with no measured cells. A break is a
cell (`\u{b}`) and `break_lines` sets a line's `end` to that break's own index,
so the closing mark is already to hand: take its style, falling back to the
paragraph mark (now carried as `end_size`/`end_face`) for a last line that no
break closes. Any other empty line -- a line of tabs alone -- keeps the old
measure, so only the two cases PowerPoint defines change.

An empty *paragraph* already landed on `end_props` through `lead`, and is
unchanged: it is the control case in the report, and the test for it passes both
before and after.

Measured on the reported fixture, second line top against PowerPoint's own
export: case A 166pt -> 130pt (131 expected), case B 166 -> 130, case C 113 ->
161 (160 expected), and the control case D 130 either way. The 1pt that remains
is the offset the control already had.
2026-10-15 12:33:20 +05:00
debarghaya 0fea876101 Web: point web app favicon to deckcraft.ico 2026-10-15 12:06:20 +05:30
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
Fabian 283be17f93 fix: DeckCraft upstream #93
Keep the keyboard while editing list text: Tab and the arrow keys moved
egui's keyboard focus onto ordinary widgets, and that stray focus
swallowed every later key, so lists could not be typed into or continued
with Enter.
2026-10-13 00:38:29 +02:00
295 changed files with 17038 additions and 12285 deletions
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+18 -45
View File
@@ -1,55 +1,28 @@
name: ci
name: CI
# Quality gates for every push to main and every pull request: rustfmt, clippy (-D warnings),
# tests, asset attribution, crate layering and the wasm32 check — `cargo xtask ci`.
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Same craft-fonts commit as release.yml (Latin presentation fonts).
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gates:
timeout-minutes: 60
name: gates (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
# release.yml builds macOS (a failing release is fixed and rebuilt).
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: ci-${{ matrix.os }}
- name: cargo xtask ci
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
run: cargo xtask ci
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
packages: >-
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev
libgl1-mesa-dev
+6 -5
View File
@@ -2,16 +2,17 @@
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow itself still run it.
on:
push:
branches: [main]
branches: [release]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "crates/fonts/**"
- .github/workflows/freebsd.yml
schedule:
- cron: "27 2 * * *"
workflow_dispatch:
concurrency:
+23
View File
@@ -0,0 +1,23 @@
# 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:
nix:
# 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/nix.yml@main
+21
View File
@@ -0,0 +1,21 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+31
View File
@@ -0,0 +1,31 @@
# 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/**
# Nightly, so dependency and runner-image drift shows up within a day.
schedule:
- cron: "3 5 * * *"
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
+2 -1
View File
@@ -131,11 +131,12 @@ Standard: craftrules `standards/never-crash.md`.
CLI, control channel and MCP do exactly the same thing.
- **The UI is thin**: panels read engine state and act through commands. Colours come from theme
tokens. **Never use Tauri**; egui only.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Never break wasm** (`cargo xtask wasm`).
## 5. Quality gates (before every commit)
`cargo xtask ci` = fmt check, clippy `-D warnings`, tests, layering check, asset check, wasm
`cargo xtask ci` = fmt check, clippy `-D warnings`, tests (nextest when installed) and doctests, layering check, asset check, wasm
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`) and push
to `main`.
+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
+145 -189
View File
@@ -444,30 +444,15 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec 0.8.0",
]
[[package]]
name = "bit-set"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
dependencies = [
"bit-vec 0.9.1",
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit-vec"
version = "0.9.1"
@@ -605,6 +590,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 +805,23 @@ dependencies = [
"windows 0.54.0",
]
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -877,15 +885,17 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"arboard",
"chrono",
"cpal",
"deckcraft-engine",
"deckcraft-media",
"deckcraft-ui-egui",
"eframe",
"egui",
"fmv-macos-events",
"image",
"log",
"rfd",
@@ -896,7 +906,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -905,7 +915,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -914,31 +924,33 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"proptest",
"craft-media",
]
[[package]]
name = "deckcraft-cli"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
"serde_json",
"zip",
]
[[package]]
name = "deckcraft-color"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"chrono",
"deckcraft-anim",
"deckcraft-color",
"deckcraft-fonts",
@@ -962,7 +974,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -974,7 +986,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-model",
"serde",
@@ -985,7 +997,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"kurbo",
"serde",
@@ -993,7 +1005,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1002,7 +1014,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1011,19 +1023,21 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-bitstream",
"thiserror 2.0.21",
"craft-media",
]
[[package]]
name = "deckcraft-matroska"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-mcp"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1033,7 +1047,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1051,7 +1065,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"image",
"kurbo",
@@ -1060,7 +1074,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1072,30 +1086,33 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.7.0"
version = "0.10.0"
[[package]]
name = "deckcraft-opus"
version = "0.7.0"
version = "0.10.0"
[[package]]
name = "deckcraft-pdf"
version = "0.7.0"
version = "0.10.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.10.0"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1111,7 +1128,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1130,7 +1147,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1144,7 +1161,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1166,7 +1183,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1175,7 +1192,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -1586,12 +1603,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"
@@ -1619,10 +1630,15 @@ dependencies = [
]
[[package]]
name = "fnv"
version = "1.0.7"
name = "fmv-macos-events"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
[[package]]
name = "foldhash"
@@ -2005,7 +2021,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 +2029,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 +2036,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"
@@ -2252,7 +2251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error 2.0.1",
"quick-error",
]
[[package]]
@@ -2430,15 +2429,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=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
dependencies = [
"base64",
"bumpalo",
"flate2",
"float-cmp",
"gif",
"hayro-write",
"image-webp",
"imagesize",
"indexmap",
@@ -2588,6 +2585,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"
@@ -2631,7 +2638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [
"arrayvec",
"bit-set 0.10.0",
"bit-set",
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
@@ -2698,6 +2705,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 +3111,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"
@@ -3367,15 +3389,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "presser"
version = "0.3.1"
@@ -3406,37 +3419,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proptest"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set 0.8.0",
"bit-vec 0.8.0",
"bitflags 2.13.2",
"num-traits",
"rand",
"rand_chacha",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -3473,44 +3461,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core",
]
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -3625,12 +3575,6 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "renderdoc-sys"
version = "1.1.0"
@@ -3717,18 +3661,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error 1.2.3",
"tempfile",
"wait-timeout",
]
[[package]]
name = "rustybuzz"
version = "0.20.1"
@@ -4366,7 +4298,7 @@ dependencies = [
"fax",
"flate2",
"half",
"quick-error 2.0.1",
"quick-error",
"weezl",
"zune-jpeg",
]
@@ -4513,12 +4445,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -4615,7 +4541,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 +4556,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",
@@ -4674,15 +4600,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4758,6 +4675,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"
@@ -4972,8 +4928,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
dependencies = [
"arrayvec",
"bit-set 0.10.0",
"bit-vec 0.9.1",
"bit-set",
"bit-vec",
"bitflags 2.13.2",
"bytemuck",
"cfg_aliases",
@@ -5045,7 +5001,7 @@ dependencies = [
"android_system_properties",
"arrayvec",
"ash",
"bit-set 0.10.0",
"bit-set",
"bitflags 2.13.2",
"block2 0.6.2",
"bytemuck",
@@ -5663,7 +5619,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.7.0"
version = "0.10.0"
dependencies = [
"serde_json",
]
+15 -4
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.10.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]
@@ -49,7 +49,9 @@ rayon = "1"
unicode-bidi = "0.3"
unicode-segmentation = "1"
unicode-linebreak = "0.1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# Base features are wasm-safe (`std` only). The non-wasm `clock` feature is requested
# per-crate for targets that read the local clock (see crates/text).
chrono = { version = "0.4", default-features = false, features = ["std"] }
# text / rendering / formats
skrifa = "0.47"
@@ -57,7 +59,8 @@ 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-media = { git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
i_overlay = "9"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
@@ -82,14 +85,22 @@ self_named_constructors = "allow"
large_enum_variant = "allow"
result_large_err = "allow"
# Line tables keep file and line in panics and backtraces; full debug info
# for every test binary and dependency would multiply the target dir size.
# The test profile inherits both settings.
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", 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.
+52 -9
View File
@@ -7,15 +7,19 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png" width="128" height="128" alt="DeckCraft icon: a presentation screen with a bar chart on an orange tile">
</p>
<h1 align="center">DeckCraft</h1>
<p align="center">
<b>Presentations and slide shows; an open-source, clean-room reimplementation of Microsoft PowerPoint, rebuilt in pure Rust.</b>
<b>Presentations and slide shows; an open-source, modern presentation and slide designer, built in pure Rust.</b>
</p>
<p align="center">
Build decks with themes, layouts, shapes, pictures, tables, charts, transitions and animations,
open and save PowerPoint files, and present them full screen. Native on macOS, Windows, Linux
open and save .pptx presentation files, and present them full screen. Native on macOS, Windows, Linux
and FreeBSD, and in the browser. Every button is also a command that the CLI and AI agents
(through MCP) can drive.
</p>
@@ -25,6 +29,9 @@
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue?style=flat-square">
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-b7410e?style=flat-square">
<img alt="Platforms" src="https://img.shields.io/badge/platforms-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-555?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_deckcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/deckcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -74,7 +81,7 @@ Today it can:
audio and video (MP3, AAC, FLAC, ALAC, Ogg, Opus, WAV, AIFF; H.264, HEVC, VP9, AV1).
- **Present**: transitions (including Morph), entrance/emphasis/exit and motion-path animations,
by-paragraph builds, full-screen slide show, presenter view, pen, rehearse timings, media playback.
- **Exchange files**: open and save PowerPoint `.pptx`, export PDF (slides, notes pages, handouts
- **Exchange files**: open and save .pptx presentations, export PDF (slides, notes pages, handouts
with selectable text), PNG/JPEG, an animated GIF of the deck and outlines. Unsaved work is kept by AutoRecover.
- **Automate**: 200+ commands with undo, all scriptable from the CLI, a JSON control channel and an
MCP server for AI agents.
@@ -97,6 +104,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
@@ -129,7 +139,7 @@ settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/deckcraf
`$XDG_CONFIG_HOME/deckcraft/logs/`; macOS `~/Library/Application Support/DeckCraft/logs/`; Windows
`%APPDATA%\DeckCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so this file
is what to attach to a bug report: the crash guard's panic report, a failed crash-recovery save,
PowerPoint parts skipped on import and audio output problems land there. Each launch moves the
Parts skipped on import and audio output problems land there. Each launch moves the
previous log to `deckcraft.1.log` (and that one to `deckcraft.2.log`), so the log of a run that
crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file.
@@ -155,8 +165,8 @@ deckcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # con
## Roadmap
DeckCraft is in **alpha**. It covers **84% of PowerPoint's features by breadth** (92% of the core
ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated).
DeckCraft is in **alpha**. It covers **84% of the features tracked in [`docs/target-app-parity.md`](docs/target-app-parity.md) by breadth** (92% of the core
ones, measured) and is about **55% ready for real work across all tracked capabilities** (estimated).
See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next,
[docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment,
[docs/gaps.md](docs/gaps.md) for every known shortfall and
@@ -214,6 +224,41 @@ DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcom
RISC-V 64 (riscv64): `deckcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (cross-compiled; needs glibc 2.39+, e.g. Ubuntu 24.04+).
**Runtime libraries.** The window-system, GPU and file-dialog libraries DeckCraft needs are opened
when the app starts (`dlopen` through `x11-dl`, `xkbcommon-dl`, `ash`, `glutin` and `rfd`) instead
of being linked in, so `ldd` shows none of them: an install that satisfies the linked dependencies
can still fail at start-up with a loader error and no hint of what to install. Only
`libasound.so.2` (slide-show sound) is a hard link-time dependency, and only of the `deckcraft`
GUI — `deckcraft-cli` uses neither sound nor a display. The `.deb` and `.rpm` declare the set in
`packaging/linux/nfpm.yaml`; a tarball install or a musl system has to provide it itself:
```sh
apk add libx11 libxcursor libxrandr libxi libxkbcommon xkeyboard-config \
wayland-libs-client alsa-lib \
vulkan-loader mesa-vulkan-swrast \
mesa-gl mesa-egl mesa-dri-gallium \
xdg-desktop-portal
```
Alpine Linux `v3.22` names above; Void's musl variant uses the same `/lib/ld-musl-x86_64.so.1`
loader and the same libraries as `libX11 libXcursor libXrandr libXi libxkbcommon xkeyboard-config
wayland alsa-lib vulkan-loader mesa xdg-desktop-portal`. There is no musl build among the release
artifacts yet — this is what a build from source on musl needs at run time, and what a future musl
artifact will need (#954).
- `mesa-vulkan-swrast` is the software Vulkan driver: it runs anywhere, VMs and containers
included. On a real GPU install your vendor's driver instead (`mesa-vulkan-intel`,
`mesa-vulkan-ati`, `mesa-vulkan-nouveau`, …).
- Without Vulkan the app falls back to CPU rendering through Mesa's GL drivers. It works, but
slowly — a driver limit, not a DeckCraft bug.
- `xdg-desktop-portal` (with `xdg-desktop-portal-gtk` or `-kde`; `-wlr` has no file chooser, so
on wlroots compositors install `zenity`) is what the Open and Save dialogs talk to; `xkeyboard-config` holds the XKB keyboard data `libxkbcommon` reads.
- fontconfig and freetype are not needed: DeckCraft reads `/usr/share/fonts` itself and embeds its
presentation fonts.
Building from source on musl also needs `build-base pkgconf alsa-lib-dev` (`alsa-lib-dev` is the
ALSA headers).
### FreeBSD
| Build | File |
@@ -276,15 +321,13 @@ Bundled fonts, icons, images and other assets keep their own open licenses; each
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
DeckCraft's themes, layouts, shape geometry, icons and sample decks are original work, drawn or
generated in code; no Microsoft artwork, fonts, themes or templates are used.
generated in code; no proprietary artwork, fonts, themes or templates are used.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and DeckCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Microsoft and PowerPoint are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. DeckCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+80 -1
View File
@@ -2,7 +2,10 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -131,6 +134,59 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
| 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) |
| 2026-10-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms |
| 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All |
| 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) |
| 2026-10-23 | A shape with no fill and no line (a text box) no longer draws its shape-level glow as a halo round its box; the text's own glow is all PowerPoint shows |
| 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) |
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
| 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again |
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
| 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
| 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) |
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
| 2026-10-18 | Slide show: a video or audio clip is looked up on the slide it belongs to, so slides whose shape ids repeat (the PPTX import numbers each slide afresh) no longer play each other's clips (#994) |
| 2026-10-18 | About: Contributors and Models get their own fixed-size block below the tab row, so the tab row is no longer squeezed into the credits and the tabs keep answering (#843) |
| 2026-10-19 | README: the Linux runtime libraries a tarball or musl install has to provide — the `dlopen`ed X11/Vulkan/GL/dialog libraries with their Alpine and Void package names, the software-Vulkan fallback, and why fontconfig and freetype are not among them (#955) |
| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
| 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) |
| 2026-10-16 | Symbol-font characters: a run's U+F0xx symbol code (from `<a:sym typeface="Symbol"/>`) now draws from the installed Symbol face even when it has no private-use mapping — Apple's Symbol on macOS maps the glyphs to real Unicode, so the code is remapped to that character (the Adobe Symbol encoding) instead of falling to a look-alike. Bullets and metafile text take the same remap |
| 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection |
| 2026-10-18 | Charts: a series' own data-label setting is honoured — numbers PowerPoint shows on a graph no longer disappear, and a bare `c:showVal` means on (#868) |
| 2026-10-15 | Render: quick styles render on empty placeholders — the fill gate now checks for style and line overrides too (#832) |
| 2026-10-15 | Canvas: the resize cursor follows the handle's rotated diagonal — rotated shapes showed the wrong diagonal cursor (#745) |
| 2026-10-20 | Tests: the Options dialog's Cancel, Esc and close button put the settings back the way they were (#878) |
| 2026-10-20 | Tests: Ctrl+A in the slide sorter selects every slide, as in PowerPoint (#936) |
| 2026-10-18 | Thumbnails: navigating slides with the arrow keys scrolls the thumbnail pane to keep the current slide visible (#917) |
| 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-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
| 2026-10-16 | Fitted canvas: discrete mouse-wheel steps navigate immediately, including rapid and batched input, while trackpad gestures retain their cooldown and zoomed slides still pan. |
| 2026-10-16 | PPTX import: a paragraph build edited in PowerPoint (paragraphs with their own start, other shapes' effects in between, builds by 2nd-level paragraphs) takes PowerPoint's clicks in the show; it used to be rebuilt as one build, 34 clicks instead of 10 on the reporter's slide. Paragraph builds with sub-points save as PowerPoint writes them (#844) |
| 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
| 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) |
| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off |
| 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) |
| 2026-10-15 | Canvas: the Format and Animation Painter override the cursor to show the tool is armed (#830) |
| 2026-10-15 | Edit Points: right-click a segment for Straight/Curved Segment, a point for Open/Close Path (`shape.segmentType`, `shape.openPath`, `shape.closePath`); Delete Segment and Add Point in the menu still to come |
| 2026-10-15 | Design › Browse for Themes… with a `.pptx` or `.potx` brings its slide master and layouts, not just the theme: the master the current slide uses is replaced (a preserved one stays beside it), the template stretches to the deck's slide size, and each slide moves to the layout with the same name or kind and keeps its content. One undo step (#807) |
| 2026-10-15 | Clicking the frame of a text box or placeholder selects the box whole, so it can be moved, duplicated or deleted; only a click inside puts the caret in the text (#787) |
| 2026-10-15 | Review › Compare: Reject and Reject All drop revisions without applying them (`review.reject`); Accept All skips what was rejected |
| 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) |
@@ -189,6 +245,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read |
| 2026-10-13 | Saving a presentation keeps the embedded workbook behind an unchanged chart, and the chart part's relationships (#335). Editing the chart drops that workbook instead of writing numbers that no longer match it |
| 2026-10-13 | A presentation whose object ids are used up refuses to grow instead of handing out duplicate ids (#407) |
| 2026-10-13 | macOS: a Finder double-click, Open With, a drop on the Dock icon and `open -a DeckCraft deck.pptx` open the file (#138, #347). `Info.plist` now declares the documents DeckCraft opens (`.deckcraft`, `.pptx`, `.potx`, `.ppsx`, the same set as the Windows installer and the Linux desktop file, kept equal by a test), and the `kAEOpenDocuments` Apple event is handled through the pinned `fmv-macos-events` crate as in GridCraft; the `-psn_` argument older macOS adds on a Finder launch is ignored. PowerPoint stays the default until the user picks DeckCraft |
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
@@ -221,6 +278,27 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
| 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged |
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
| 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
| 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) |
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
| 2026-10-22 | trivial | Progress log: table text takes the master's other-text style |
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
| 2026-10-21 | trivial | Progress log: 3-D sizes in scaled groups stay in points |
| 2026-10-22 | trivial | Progress log: chart series pattern and no fills |
| 2026-10-15 | trivial | Progress log: Browse for Themes applies a template's master and layouts (#807) |
| 2026-10-15 | trivial | Progress log: Compare Reject and Reject All |
| 2026-10-16 | trivial | Progress log: PPTX paragraph builds edited in PowerPoint keep their clicks (#844) |
| 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 |
@@ -272,6 +350,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-13 | trivial | Progress log: Grid Options dialog |
| 2026-10-14 | trivial | Progress log: pen pressure scales ink width |
| 2026-10-13 | trivial | Progress log: chart workbook round-trip (#335) |
| 2026-10-13 | trivial | Progress log: Finder opens presentations on macOS (#138) |
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
| 2026-10-12 | minor | Progress log: table cell editing and border resizing |
+3
View File
@@ -17,3 +17,6 @@ path = "src/main.rs"
deckcraft-engine = { workspace = true }
deckcraft-mcp = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
zip = { workspace = true }
+5 -4
View File
@@ -19,15 +19,16 @@ The shapes of one slide, with their ids, boxes and text, come from the slide.ins
},
Subcommand {
name: "render",
usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT",
usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] [--transparent] OUT",
summary: "draw slides as PNG images",
details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first;
OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per
slide. Prints each image's path and size.
--slide N the slide at index N; indices start at 0, and 0 is the default
--scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
--edit draw what the editor shows: placeholder prompts and their dashed outlines
--slide N the slide at index N; indices start at 0, and 0 is the default
--scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
--edit draw what the editor shows: placeholder prompts and their dashed outlines
--transparent leave the slide background out, so only what is on the slide is drawn
deckcraft-cli render deck.deckcraft --slide 2 slide-3.png",
},
+36 -3
View File
@@ -117,11 +117,27 @@ fn open(s: &mut Session, input: Option<&str>, sample: bool) -> Result<(), String
return deckcraft_engine::sample::open_sample(s).map_err(|e| e.to_string());
}
match input {
Some(p) => s.execute("file.open", &json!({"path": p})).map(|_| ()).map_err(|e| e.to_string()),
Some(p) => {
let opened = s.execute("file.open", &json!({"path": p})).map_err(|e| e.to_string())?;
warn_if_damaged(p, &opened);
Ok(())
}
None => s.execute("file.new", &json!({})).map(|_| ()).map_err(|e| e.to_string()),
}
}
/// Say on stderr what a file lost on opening, so a saved copy doesn't silently miss it (#203).
fn warn_if_damaged(path: &str, opened: &serde_json::Value) {
let count = |key: &str| opened.get(key).and_then(|v| v.as_array()).map_or(0, |a| a.len());
let (slides, media) = (count("lostSlides"), count("damagedMedia"));
if slides > 0 {
eprintln!("deckcraft-cli: warning: {path}: {slides} slide{} couldn't be read and left out", if slides == 1 { "" } else { "s" });
}
if media > 0 {
eprintln!("deckcraft-cli: warning: {path}: {media} picture or media file{} couldn't be read", if media == 1 { "" } else { "s" });
}
}
/// Median and slowest of `runs` timings of `f`, in milliseconds.
fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f64, f64), String> {
let mut ms = Vec::with_capacity(runs);
@@ -244,11 +260,13 @@ fn render(args: &[String]) -> Result<(), String> {
let mut all = false;
let mut scale = 2.0;
let mut edit = false;
let mut transparent = false;
let mut out = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--sample" => sample = true,
"--transparent" => transparent = true,
"--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?),
"--all" => all = true,
"--edit" => edit = true,
@@ -281,7 +299,7 @@ fn render(args: &[String]) -> Result<(), String> {
std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?;
}
for i in list {
let (png, w, h) = deckcraft_engine::cmd::file::render_png(&st.doc, i, scale, edit);
let (png, w, h) = deckcraft_engine::cmd::file::render_png_with(&st.doc, i, scale, edit, transparent);
let path = if all { format!("{out}/slide-{:02}.png", i + 1) } else { out.clone() };
std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?;
outln!("{path} ({w}×{h})");
@@ -389,7 +407,7 @@ fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
return Ok(vec![out.to_string()]);
}
let p = std::path::Path::new(out);
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame");
let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("frame");
let mut paths: Vec<String> = Vec::with_capacity(ts.len());
for t in ts {
let path = p.with_file_name(format!("{stem}-t{t:.3}.png")).to_string_lossy().into_owned();
@@ -680,6 +698,21 @@ mod tests {
}
}
/// `render --transparent` leaves the slide background out (#784): the corner is transparent.
#[test]
fn render_transparent_leaves_the_background_out() {
let dir = std::env::temp_dir().join(format!("deckcraft-cli-transparent-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let alpha = |path: &std::path::Path| deckcraft_engine::render::decode_image(&std::fs::read(path).unwrap()).unwrap().get_pixel(1, 1).0[3];
let opaque = dir.join("opaque.png");
render(&args(&["--sample", "--scale", "0.25", &opaque.to_string_lossy()])).unwrap();
assert_eq!(alpha(&opaque), 255);
let clear = dir.join("clear.png");
render(&args(&["--sample", "--transparent", "--scale", "0.25", &clear.to_string_lossy()])).unwrap();
assert_eq!(alpha(&clear), 0);
let _ = std::fs::remove_dir_all(&dir);
}
/// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs.
#[test]
fn exports_an_animated_gif() {
+53
View File
@@ -0,0 +1,53 @@
//! Opening a `.pptx` that lost slides says so on stderr instead of saving a shorter deck in silence (#203).
use std::io::{Cursor, Read, Write};
use std::path::PathBuf;
use std::process::{Command, Output};
fn cli(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("deckcraft-cli-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// `deck` with `ppt/slides/slide2.xml` cut off.
fn without_slide2(deck: &[u8]) -> Vec<u8> {
let mut zin = zip::ZipArchive::new(Cursor::new(deck)).unwrap();
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
for i in 0..zin.len() {
let mut f = zin.by_index(i).unwrap();
let mut data = vec![];
f.read_to_end(&mut data).unwrap();
if f.name() == "ppt/slides/slide2.xml" {
data = b"<p:sld".to_vec();
}
out.start_file(f.name(), options).unwrap();
out.write_all(&data).unwrap();
}
out.finish().unwrap().into_inner()
}
#[test]
fn a_pptx_with_an_unreadable_slide_is_reported_on_stderr() {
let dir = temp_dir("lost-slide");
let good = dir.join("good.pptx");
let made = cli(&["run", "--sample", "--save", good.to_str().unwrap()]);
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
let broken = dir.join("broken.pptx");
std::fs::write(&broken, without_slide2(&std::fs::read(&good).unwrap())).unwrap();
let ok = cli(&["info", good.to_str().unwrap()]);
assert!(ok.status.success() && !String::from_utf8_lossy(&ok.stderr).contains("warning"), "{}", String::from_utf8_lossy(&ok.stderr));
let bad = cli(&["info", broken.to_str().unwrap()]);
assert!(bad.status.success(), "{}", String::from_utf8_lossy(&bad.stderr));
let err = String::from_utf8_lossy(&bad.stderr);
assert!(err.contains("warning") && err.contains("1 slide couldn't be read and left out"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
+6
View File
@@ -0,0 +1,6 @@
mod cli;
mod convert;
mod damaged;
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod mcp_roots;
@@ -1,6 +1,4 @@
//! `deckcraft-cli mcp` with the automation root flags, run as the real binary.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::path::PathBuf;
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>DeckCraft</title>
<link data-trunk rel="rust" data-bin="deckcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="icon" href="../../assets/app-icon/hicolor/32x32/apps/ai.storyteller.deckcraft.png" />
<link data-trunk rel="icon" href="../../assets/app-icon/deckcraft.ico" />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#deckcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
+8
View File
@@ -18,6 +18,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for deckcraft.exe; build.rs only uses it when the target is Windows.
@@ -29,5 +30,12 @@ arboard = { workspace = true, features = ["image-data"] }
image = { workspace = true }
cpal = "0.16"
[target.'cfg(target_os = "macos")'.dependencies]
# Finder double-click, Open With, Dock drops and `open -a DeckCraft deck.pptx` arrive as a
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This audited crate (also used by
# GridCraft, PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which
# our workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
+53
View File
@@ -0,0 +1,53 @@
//! macOS open-documents and quit Apple events.
//!
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a DeckCraft deck.pptx` don't
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
//! `NSApplicationDelegate`. Handling it ourselves needs Objective-C class declarations, i.e.
//! `unsafe`, which this workspace forbids. The audited `fmv-macos-events` crate (also used by
//! GridCraft, PdfCraft and PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler
//! registered before Finder's launch event that leaves winit's delegate alone, behind a safe
//! main-thread API.
use deckcraft_ui_egui::SlideApp;
use fmv_macos_events::{Event, Inbox, Registration};
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
_registration: Registration,
inbox: Inbox,
}
impl AppleEvents {
/// Register the handlers. Call on the main thread before the event loop starts, so the event
/// that launched the app (a Finder double-click) is caught too.
pub fn install() -> Self {
let (registration, inbox) = Registration::install();
Self { _registration: registration, inbox }
}
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
let ctx = ctx.clone();
self.inbox.set_wake(move || ctx.request_repaint());
self.inbox.clone()
}
}
/// Open the documents and act on the quit requests that arrived since the last frame.
pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => {
for p in paths.iter().filter_map(|p| p.to_str()) {
if let Err(e) = app.open_path(p) {
let safe_p = p.replace(['\r', '\n'], " ");
log::error!("{safe_p}: {e}");
app.set_status(format!("Couldn't open {safe_p}: {e}"));
}
}
}
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
}
+229 -19
View File
@@ -1,15 +1,18 @@
//! 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};
use std::net::{Shutdown, TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
use std::time::Duration;
use std::time::{Duration, Instant};
use deckcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
@@ -58,12 +61,22 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
}
}
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
/// example while the process has no file descriptors left, and retrying at once would keep a
/// core busy until it recovers.
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// The connections `incoming` accepts, calling `wait` after each failed accept.
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
}
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
let active = Arc::new(AtomicUsize::new(0));
std::thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
let _ = stream.set_write_timeout(Some(limits.write_timeout));
if active.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
active.fetch_sub(1, Ordering::SeqCst);
@@ -120,6 +133,82 @@ 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/"))
}
/// Close after the last reply so the peer can still read it. A peer that had sent another line
/// before the reply arrived -- two lines in one write, which any script does -- leaves input
/// unread in the socket, and closing a socket with unread input resets the connection: the reply
/// already queued for the peer is discarded and it sees only the reset. Sending the FIN first and
/// then draining, briefly and boundedly, makes the close orderly and the reason survive.
fn close_politely(out: &TcpStream, reader: &mut BufReader<TcpStream>, grace: Duration) {
if out.shutdown(Shutdown::Write).is_err() {
return;
}
// Enough for a pipelined line or two; a peer that keeps talking is dropped at the deadline.
// The timeout is what is left of the grace before each read, not the whole of it: a peer that
// sends a byte just before each one expires would otherwise hold this thread, and its slot,
// for close to twice as long as the deadline says.
const MAX_DRAIN: usize = 64 << 10;
let until = Instant::now() + grace;
let mut sink = [0u8; 4 << 10];
let mut drained = 0;
while drained < MAX_DRAIN {
let left = until.saturating_duration_since(Instant::now());
if left.is_zero() || out.set_read_timeout(Some(left)).is_err() {
break;
}
match reader.read(&mut sink) {
Ok(0) | Err(_) => break,
Ok(n) => drained += n,
}
}
}
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;
@@ -132,29 +221,40 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
Line::Text(t) => t,
Line::TooLong => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("request line longer than {} bytes", limits.max_line)}));
close_politely(&out, &mut reader, limits.write_timeout);
break;
}
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, but the peer is told why first: see
// `close_politely`, without which a peer that had pipelined another line had this
// reply reset away and saw nothing.
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
close_politely(&out, &mut reader, limits.write_timeout);
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 +264,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) {
@@ -178,6 +277,18 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
#[cfg(test)]
mod tests {
use super::*;
/// A failed accept is followed by a wait before the next one, so a listener that keeps
/// failing does not spin, and the connections around the failures still come through.
#[test]
fn a_failed_accept_waits_before_the_next() {
let failed = || Err(std::io::Error::other("no file descriptors left"));
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
let mut waits = 0;
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
assert_eq!(connections, [1, 2]);
assert_eq!(waits, 3, "one wait per failed accept");
}
use std::net::SocketAddr;
use std::time::Instant;
@@ -232,6 +343,66 @@ 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 /\"}"));
}
/// A peer that pipelines -- two lines in one write, as a script does -- still reads why its
/// line was rejected. Closing with its second line unread resets the connection and discards
/// the reply already on its way, so the peer saw nothing at all; repeated here because it
/// depended on the timing of the two.
#[test]
fn a_pipelined_peer_still_reads_why_its_line_was_rejected() {
let (addr, rx) = server(small());
for _ in 0..12 {
let (mut r, mut w) = client(addr);
w.write_all(b"hello\n").unwrap();
// A second line, written after the first as a script writes it: it races the close.
let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n");
let replies = replies_until_closed(&mut r);
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
assert!(replies[0]["error"].as_str().unwrap_or_default().contains("closing the connection"), "{replies:?}");
}
// The line after the rejected one is never dispatched, pipelined or not.
assert!(rx.try_recv().is_err(), "a line after the rejection was dispatched");
}
#[test]
fn oversized_line_closes_connection() {
let (addr, rx) = server(small());
@@ -296,4 +467,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();
}
}
+36 -19
View File
@@ -95,29 +95,35 @@ fn matches(name: &str, target: &str) -> bool {
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
/// instead of panicking.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
/// clock can't overflow).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -389,6 +395,17 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
// with a checked add and only asserted where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
+116 -7
View File
@@ -1,6 +1,7 @@
//! DeckCraft desktop app.
//!
//! Usage: `deckcraft [--control <port>] [--sample] [--show] [files…]`
//! A PowerPoint Show (`.ppsx`) given last starts its slide show, as a double-click does in PowerPoint.
//! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits.
//!
//! `--control <port>` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
@@ -13,6 +14,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(target_os = "macos")]
mod apple_events;
mod audio;
mod control_server;
#[cfg(all(unix, not(target_os = "macos")))]
@@ -26,10 +29,17 @@ mod pasteboard;
use deckcraft_engine::Session;
use deckcraft_ui_egui::{Services, SlideApp};
struct App(SlideApp);
struct App(
SlideApp,
/// Files opened from Finder and quit requests (see `apple_events`).
#[cfg(target_os = "macos")]
fmv_macos_events::Inbox,
);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
apple_events::poll(&self.1, &mut self.0, ctx);
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
@@ -134,6 +144,8 @@ Usage: deckcraft [--control PORT] [--sample] [--show] [FILE...]
--show start the slide show
--help, -h print this help and exit
--version, -V print the version and exit
A PowerPoint Show (.ppsx) given last starts its slide show.
";
enum Startup {
@@ -155,6 +167,8 @@ fn startup(mut control_port: Option<u16>, args: impl IntoIterator<Item = String>
"--show" => show = true,
"--version" | "-V" => return Startup::Version,
"--help" | "-h" => return Startup::Help,
// The process serial number older macOS adds when Finder launches the app.
a if a.starts_with("-psn_") => {}
_ => files.push(a),
}
}
@@ -231,6 +245,12 @@ fn main() -> eframe::Result {
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
eframe::run_native(
"DeckCraft",
options,
@@ -254,15 +274,18 @@ fn main() -> eframe::Result {
if sample && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
log::error!("sample: {e}");
}
for f in files {
if let Err(e) = app.open_path(&f) {
log::error!("{f}: {e}");
}
// A .ppsx opened last starts its slide show (#862).
for (f, e) in app.open_from_system(&files) {
log::error!("{f}: {e}");
}
if show {
app.start_show(0, false);
}
Ok(Box::new(App(app)))
Ok(Box::new(App(
app,
#[cfg(target_os = "macos")]
apple_events.connect(&cc.egui_ctx),
)))
}),
)
}
@@ -276,7 +299,11 @@ mod tests {
#[test]
fn on_exit_ends_the_process_on_macos() {
if std::env::var_os("DECKCRAFT_TEST_ON_EXIT").is_some() {
let mut app = App(SlideApp::new(Session::new(), Services::default()));
let mut app = App(
SlideApp::new(Session::new(), Services::default()),
#[cfg(target_os = "macos")]
fmv_macos_events::Inbox::default(),
);
println!("calling on_exit");
eframe::App::on_exit(&mut app);
println!("on_exit returned");
@@ -309,4 +336,86 @@ mod tests {
assert!(!show);
}
}
#[test]
fn finder_process_serial_number_is_not_a_file() {
let launch = startup(None, ["-psn_0_1234567".to_string(), "talk.pptx".to_string()]);
assert!(matches!(launch, Startup::Run { .. }));
if let Startup::Run { files, .. } = launch {
assert_eq!(files, ["talk.pptx".to_string()]);
}
}
/// The `<string>` values of every `<array>` that follows a `<key>{key}</key>` line.
fn plist_strings(plist: &str, key: &str) -> Vec<String> {
let mut out = Vec::new();
let mut in_array = false;
for line in plist.lines() {
let line = line.trim();
if line == format!("<key>{key}</key>") {
in_array = true;
continue;
}
if in_array {
for part in line.split("<string>").skip(1) {
if let Some((s, _)) = part.split_once("</string>") {
out.push(s.to_string());
}
}
if line.ends_with("</array>") {
in_array = false;
}
}
}
out
}
/// Every desktop offers the same file types: macOS (`CFBundleTypeExtensions` and the native
/// type's extension in `Info.plist.in`), Windows (`SupportedTypes` in `deckcraft.wxs`) and
/// Linux (`MimeType` in the desktop file). A format added to one installer must reach the
/// others, and each must be one Finder, Explorer or the file manager can hand to DeckCraft.
#[test]
fn every_desktop_offers_the_same_file_types() {
let plist = include_str!("../../../packaging/macos/Info.plist.in");
let wxs = include_str!("../../../packaging/windows/deckcraft.wxs");
let desktop = include_str!("../../../packaging/linux/ai.storyteller.deckcraft.desktop");
let mut mac = plist_strings(plist, "CFBundleTypeExtensions");
// The native type is declared by its UTI; its extension is in the exported type.
mac.extend(plist_strings(plist, "public.filename-extension").into_iter().filter(|e| e == "deckcraft"));
mac.sort();
mac.dedup();
assert!(mac.iter().any(|e| e == "pptx") && mac.iter().any(|e| e == "deckcraft"), "Info.plist.in: {mac:?}");
// Every document type names a content type too, so "Change All" in Finder's Get Info works.
let names = plist.matches("<key>CFBundleTypeName</key>").count();
let content_types = plist.matches("<key>LSItemContentTypes</key>").count();
assert!(names > 0 && names == content_types, "{names} document types, {content_types} with LSItemContentTypes");
let mut win: Vec<String> = wxs
.lines()
.filter(|l| l.contains("SupportedTypes\" Name=\"."))
.filter_map(|l| l.split("Name=\".").nth(1)?.split('"').next().map(str::to_string))
.collect();
win.sort();
let mime_ext = |m: &str| match m {
"application/x-deckcraft" => Some("deckcraft"),
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => Some("pptx"),
"application/vnd.openxmlformats-officedocument.presentationml.template" => Some("potx"),
"application/vnd.openxmlformats-officedocument.presentationml.slideshow" => Some("ppsx"),
_ => None,
};
let mut linux: Vec<String> = desktop
.lines()
.find_map(|l| l.strip_prefix("MimeType="))
.unwrap_or_default()
.split(';')
.filter(|m| !m.is_empty())
.map(|m| mime_ext(m).unwrap_or(m).to_string()) // an unknown MIME type shows up in the diff
.collect();
linux.sort();
assert_eq!(mac, win, "macOS and Windows offer different file types");
assert_eq!(mac, linux, "macOS and Linux offer different file types");
}
}
+8
View File
@@ -54,3 +54,11 @@ 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
[people.GhostMitra]
sync_github_name = true
+1 -1
View File
@@ -49,7 +49,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit())
} {
i += 2;
while cs.get(i).is_some_and(|d| d.is_ascii_digit()) {
while cs.get(i).is_some_and(char::is_ascii_digit) {
i += 1;
}
break;
+11 -7
View File
@@ -299,7 +299,8 @@ impl ShowState {
ShowAction::FinishStep(n)
}
/// ← / Backspace: undo the last step, else go to the previous slide fully built.
/// ← / Backspace: undo the last step, else go to the previous slide fully built. An automatic
/// first step (With/After Previous) is part of the slide appearing, so it is never undone.
pub fn prev(&mut self, pres: &Presentation) -> ShowAction {
if self.ended {
self.ended = false;
@@ -310,13 +311,16 @@ impl ShowState {
self.playing = false;
return ShowAction::Slide(self.slide);
}
if self.playing {
// #844: undoing the automatic first step left the slide as before it appeared, and the show
// played the step again at once, so Back never got past the slide.
let undo = if self.playing { Some(self.step) } else { self.step.checked_sub(1) };
let auto_first = undo == Some(0) && !pres.show.without_animation && ShowState::timeline(pres, self.slide).step_is_auto(0);
if let Some(k) = undo
&& !auto_first
{
self.playing = false;
return ShowAction::StepBack(self.step);
}
if self.step > 0 {
self.step -= 1;
return ShowAction::StepBack(self.step);
self.step = k;
return ShowAction::StepBack(k);
}
let target = match self.prev_index(pres) {
Some(i) => Some(i),
+54
View File
@@ -344,6 +344,36 @@ fn empty_slide() {
assert_eq!(tl.locate(5.0), (0, 0.0));
}
#[test]
fn start_of_seeks_the_play_all_clock_to_an_effect() {
let mut late = anim(4, AnimClass::Entrance, "fade", AnimStart::WithPrevious);
late.delay_ms = 250;
let mut triggered = anim(4, AnimClass::Emphasis, "pulse", AnimStart::OnClick);
triggered.trigger = Some(ShapeId(1));
let tl = Timeline::new(
&slide_with(vec![
anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick),
anim(2, AnimClass::Exit, "fadeOut", AnimStart::OnClick),
anim(3, AnimClass::Entrance, "fade", AnimStart::OnClick),
late,
triggered,
]),
W,
H,
);
assert_eq!(tl.start_of(0), Some(0.0));
assert_eq!(tl.start_of(2), Some(2.0));
assert_eq!(tl.start_of(3), Some(2.25));
assert_eq!(tl.start_of(4), None, "a triggered effect is not on the play-all clock");
assert_eq!(tl.start_of(9), None);
// Playing from the third effect: what came before has played, the rest has not.
let at = tl.start_of(2).unwrap();
assert!(tl.state_at(ShapeId(1), None, at).visible, "entered before");
assert!(!tl.state_at(ShapeId(2), None, at).visible, "exited before");
assert!(!tl.state_at(ShapeId(3), None, at).visible, "not entered yet");
assert!(!tl.state_at(ShapeId(4), None, at).visible, "not entered yet");
}
#[test]
fn locate_play_all_clock() {
let tl = Timeline::new(
@@ -808,6 +838,30 @@ fn show_steps_through_builds() {
assert_eq!(s.step, 1);
}
#[test]
fn show_back_leaves_a_slide_whose_first_step_is_automatic() {
// #844: undoing the automatic first step left the slide as before it appeared, and the show
// played that step again at once, so Back never reached the slide before.
for start in [AnimStart::WithPrevious, AnimStart::AfterPrevious] {
let mut p = pres(2);
Arc::make_mut(&mut p.slides[1]).shapes = vec![shape(1, "A"), shape(2, "B")];
Arc::make_mut(&mut p.slides[1]).animations =
vec![anim(1, AnimClass::Entrance, "fade", start), anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick)];
let mut s = ShowState::new(&p);
assert_eq!(s.next(&p), ShowAction::Slide(1));
assert!(s.playing, "{start:?}");
// While the automatic step plays.
assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}");
assert_eq!(s.next(&p), ShowAction::Slide(1));
// Once it has played, the click step is undone first, then Back leaves the slide.
assert_eq!(s.next(&p), ShowAction::PlayStep(1));
assert_eq!(s.step_done(&p), ShowAction::FinishStep(1));
assert_eq!(s.prev(&p), ShowAction::StepBack(1), "{start:?}");
assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}");
assert_eq!((s.step, s.playing), (0, false));
}
}
#[test]
fn show_auto_advance() {
let mut p = pres(2);
+23 -13
View File
@@ -2,7 +2,7 @@
use std::collections::{HashMap, HashSet};
use deckcraft_model::anim::TextBuild;
use deckcraft_model::anim::{BuildRole, TextBuild, paragraph_build_roles};
use deckcraft_model::style::Fill;
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm};
@@ -12,7 +12,6 @@ use crate::effects::{EffectSpec, exit_base, motion_path};
/// Delay between paragraphs of a by-paragraph build that isn't on click (seconds).
pub const PARAGRAPH_STAGGER: f64 = 0.5;
const MAX_EFFECTS: usize = 100_000;
const MAX_PARAGRAPHS: usize = 1_000;
const MAX_ITERATIONS: f64 = 1.0e6;
/// Which sequence an effect belongs to.
@@ -198,17 +197,15 @@ impl Timeline {
(None, TextBuild::ByParagraph) => {
let mut v: Vec<Option<usize>> = vec![];
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
let filled = |p: &deckcraft_model::text::Paragraph| p.runs.iter().any(|r| !r.text.trim().is_empty());
let top = t.paragraphs.iter().filter(|p| filled(p)).map(|p| p.level).min().unwrap_or(0);
let mut owner: Option<usize> = None;
for (i, p) in t.paragraphs.iter().enumerate() {
if p.level <= top && filled(p) && v.len() < MAX_PARAGRAPHS {
owner = Some(i);
v.push(Some(i));
} else if let Some(o) = owner
&& grouped.insert((a.shape, i, o))
{
tl.groups.push((a.shape, i, o));
for (i, role) in paragraph_build_roles(t).into_iter().enumerate() {
match role {
BuildRole::Top => v.push(Some(i)),
BuildRole::Member(o) => {
if grouped.insert((a.shape, i, o)) {
tl.groups.push((a.shape, i, o));
}
}
BuildRole::Unowned => {}
}
}
}
@@ -359,6 +356,19 @@ impl Timeline {
(self.main.len(), 0.0)
}
/// Where effect `anim` (index into `slide.animations`) starts on the play-everything clock:
/// Animation Pane ▸ Play From. `None` for a triggered effect or one that isn't on the slide.
pub fn start_of(&self, anim: usize) -> Option<f64> {
let e = self
.effects
.iter()
.map(|e| &e.timing)
.filter(|e| e.anim == anim && e.sequence == Sequence::Main)
.min_by(|a, b| a.step.cmp(&b.step).then(a.start.total_cmp(&b.start)))?;
let before: f64 = self.main.iter().take(e.step).map(|s| s.duration).sum();
Some(before + e.start)
}
/// Shapes that start an interactive sequence when clicked.
pub fn trigger_shapes(&self) -> Vec<ShapeId> {
self.triggers.iter().map(|(s, _)| *s).collect()
+4 -1
View File
@@ -9,7 +9,10 @@ use std::f64::consts::PI;
use crate::easing::ease;
use crate::transition::{FULL, Layer, R, Source};
use crate::transition_geom::*;
use crate::transition_geom::{
Cam, V3, boundary, card, center, clip, dir, edge, ein, eout, facing_shade, fan, field, hash64, moved, offset, poly_layers, pow_inout, q2,
rect_poly, rnd, rot, rot_quad, scale_rect, scaled, soft_rect, ss, v3, win,
};
use Source::{Black, New, NewBack, NewContent, Old, OldBack, OldContent, White};
+3 -3
View File
@@ -1,7 +1,7 @@
//! CDEF (7.15).
use crate::frame::{FrameBuf, Plane};
use crate::spec_tables::*;
use crate::spec_tables::{CDEF_DIRECTIONS, CDEF_PRI_TAPS, CDEF_SEC_TAPS, CDEF_UV_DIR, DIV_TABLE};
use crate::state::FrameState;
/// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame
@@ -242,14 +242,14 @@ fn filter_rows<const W: usize>(
}
for i in 0..h {
let ci = ((i + 2) * PAD_STRIDE + 2) as isize;
let Some(&x) = b16.get(ci as usize..).and_then(|r| r.first_chunk::<W>()) else {
let Some(&x) = b16.get(ci as usize..).and_then(<[_]>::first_chunk::<W>) else {
return;
};
let (mut sum, mut max, mut min) = ([0i16; W], x, x);
for &(off, weight, thr, adj) in taps {
let (weight, thr) = (weight as i16, thr as i16);
let start = (ci + off) as usize;
let Some(p) = b16.get(start..).and_then(|r| r.first_chunk::<W>()) else {
let Some(p) = b16.get(start..).and_then(<[_]>::first_chunk::<W>) else {
return;
};
for j in 0..W {
+21 -1
View File
@@ -1,6 +1,26 @@
//! The adaptive CDF arrays (spec 6.8.2 init_non_coeff_cdfs / init_coeff_cdfs, load_cdfs).
use crate::spec_tables::*;
use crate::spec_tables::{
DEFAULT_ANGLE_DELTA_CDF, DEFAULT_CFL_ALPHA_CDF, DEFAULT_CFL_SIGN_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF,
DEFAULT_COMP_BWD_REF_CDF, DEFAULT_COMP_GROUP_IDX_CDF, DEFAULT_COMP_MODE_CDF, DEFAULT_COMP_REF_CDF, DEFAULT_COMP_REF_TYPE_CDF,
DEFAULT_COMPOUND_IDX_CDF, DEFAULT_COMPOUND_MODE_CDF, DEFAULT_COMPOUND_TYPE_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DELTA_LF_CDF, DEFAULT_DELTA_Q_CDF,
DEFAULT_DRL_MODE_CDF, DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF,
DEFAULT_EOB_PT_256_CDF, DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_FILTER_INTRA_CDF, DEFAULT_FILTER_INTRA_MODE_CDF,
DEFAULT_INTER_INTRA_CDF, DEFAULT_INTER_INTRA_MODE_CDF, DEFAULT_INTER_TX_TYPE_SET1_CDF, DEFAULT_INTER_TX_TYPE_SET2_CDF,
DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTERP_FILTER_CDF, DEFAULT_INTRA_TX_TYPE_SET1_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_INTRABC_CDF,
DEFAULT_IS_INTER_CDF, DEFAULT_MOTION_MODE_CDF, DEFAULT_MV_BIT_CDF, DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF,
DEFAULT_MV_CLASS0_HP_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_HP_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF, DEFAULT_NEW_MV_CDF,
DEFAULT_PALETTE_SIZE_2_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_2_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_Y_COLOR_CDF,
DEFAULT_PALETTE_SIZE_4_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_4_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_Y_COLOR_CDF,
DEFAULT_PALETTE_SIZE_6_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_6_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_Y_COLOR_CDF,
DEFAULT_PALETTE_SIZE_8_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_8_Y_COLOR_CDF, DEFAULT_PALETTE_UV_MODE_CDF, DEFAULT_PALETTE_UV_SIZE_CDF,
DEFAULT_PALETTE_Y_MODE_CDF, DEFAULT_PALETTE_Y_SIZE_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF,
DEFAULT_PARTITION_W64_CDF, DEFAULT_PARTITION_W128_CDF, DEFAULT_REF_MV_CDF, DEFAULT_RESTORATION_TYPE_CDF, DEFAULT_SEGMENT_ID_CDF,
DEFAULT_SEGMENT_ID_PREDICTED_CDF, DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_SKIP_MODE_CDF, DEFAULT_TX_8X8_CDF, DEFAULT_TX_16X16_CDF,
DEFAULT_TX_32X32_CDF, DEFAULT_TX_64X64_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_TXFM_SPLIT_CDF, DEFAULT_UNI_COMP_REF_CDF, DEFAULT_USE_OBMC_CDF,
DEFAULT_USE_SGRPROJ_CDF, DEFAULT_USE_WIENER_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_WEDGE_INDEX_CDF,
DEFAULT_WEDGE_INTER_INTRA_CDF, DEFAULT_Y_MODE_CDF, DEFAULT_ZERO_MV_CDF, FRAME_LF_COUNT, MV_CONTEXTS,
};
/// Every CDF array listed by init_non_coeff_cdfs and init_coeff_cdfs. Each innermost array holds
/// N cumulative values (the last is 32768) plus the adaptation counter.
+8 -4
View File
@@ -17,7 +17,11 @@ use crate::cdf::Cdfs;
use crate::frame::FrameBuf;
use crate::header::{FilmGrainParams, FrameHeader, HeaderState, LoopFilterDeltas, RefInfo, SegmentationFeatures, SequenceHeader, default_gm_params};
use crate::par::Pool;
use crate::spec_tables::*;
use crate::spec_tables::{
ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, DEFAULT_INTRA_FRAME_Y_MODE_CDF, DIV_MULT, GOLDEN_FRAME, INTRA_FRAME, INTRA_ONLY_FRAME, KEY_FRAME,
LAST_FRAME, LAST2_FRAME, MAX_FRAME_DISTANCE, MAX_OFFSET_HEIGHT, MAX_OFFSET_WIDTH, MFMV_STACK_SIZE, NUM_REF_FRAMES, PRIMARY_REF_NONE,
REFMVS_LIMIT, REFS_PER_FRAME,
};
use crate::state::{FrameShared, FrameState, TileState};
use crate::stats::{DecodeStats, Stage, Timer};
use crate::tile::TileDecoder;
@@ -61,7 +65,7 @@ impl<T: Clone> Slot<T> {
}
fn set(&self, v: Result<T>) {
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
*g = Some(v);
self.cv.notify_all();
}
@@ -71,12 +75,12 @@ impl<T: Clone> Slot<T> {
}
fn wait(&self) -> Result<T> {
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
loop {
if let Some(v) = g.as_ref() {
return v.clone();
}
g = self.cv.wait(g).unwrap_or_else(|e| e.into_inner());
g = self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner);
}
}
}
+3 -3
View File
@@ -16,7 +16,7 @@ const POOL_MAX_SAMPLES: usize = 96 << 20;
fn zeroed(len: usize) -> Vec<u16> {
if len >= POOL_MIN_LEN {
let found = {
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
pool.iter().position(|v| v.capacity() >= len && v.capacity() <= 2 * len).map(|i| pool.swap_remove(i))
};
if let Some(mut v) = found {
@@ -52,8 +52,8 @@ impl Drop for Plane {
fn drop(&mut self) {
if self.data.capacity() >= POOL_MIN_LEN {
let v = std::mem::take(&mut self.data);
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
let kept: usize = pool.iter().map(|b| b.capacity()).sum();
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let kept: usize = pool.iter().map(Vec::capacity).sum();
if kept + v.capacity() <= POOL_MAX_SAMPLES {
pool.push(v);
}
+8 -1
View File
@@ -1,7 +1,14 @@
//! Sequence header (5.5) and uncompressed frame header (5.9) parsing.
use crate::bits::BitReader;
use crate::spec_tables::*;
use crate::spec_tables::{
AFFINE, ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, GM_ABS_ALPHA_BITS, GM_ABS_TRANS_BITS, GM_ABS_TRANS_ONLY_BITS, GM_ALPHA_PREC_BITS,
GM_TRANS_ONLY_PREC_BITS, GM_TRANS_PREC_BITS, GOLDEN_FRAME, IDENTITY, INTER_FRAME, INTRA_ONLY_FRAME, KEY_FRAME, LAST_FRAME, MAX_SEGMENTS,
MAX_TILE_AREA, MAX_TILE_COLS, MAX_TILE_ROWS, MAX_TILE_WIDTH, NUM_REF_FRAMES, ONLY_4X4, PRIMARY_REF_NONE, REF_FRAME_LIST, REFS_PER_FRAME,
RESTORATION_TILESIZE_MAX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_SWITCHABLE, RESTORE_WIENER, ROTZOOM, SEG_LVL_ALT_Q, SEG_LVL_MAX,
SEG_LVL_REF_FRAME, SELECT_INTEGER_MV, SELECT_SCREEN_CONTENT_TOOLS, SUPERRES_DENOM_BITS, SUPERRES_DENOM_MIN, SUPERRES_NUM, SWITCH_FRAME,
TOTAL_REFS_PER_FRAME, TRANSLATION, TX_MODE_LARGEST, TX_MODE_SELECT, WARPEDMODEL_PREC_BITS,
};
use crate::{Error, Result};
pub const EIGHTTAP: u8 = 0;
+12 -3
View File
@@ -5,7 +5,16 @@ use std::sync::OnceLock;
use crate::frame::Plane;
use crate::mvpred::round2_signed64;
use crate::spec_tables::*;
use crate::spec_tables::{
BLOCK_8X8, BLOCK_SIZES, COMPOUND_AVERAGE, COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_WEDGE, DIV_LUT, DIV_LUT_BITS,
DIV_LUT_PREC_BITS, EIGHTTAP, EIGHTTAP_SHARP, EIGHTTAP_SMOOTH, FILTER_BITS, GLOBAL_GLOBALMV, GLOBALMV, II_H_PRED, II_SMOOTH_PRED, II_V_PRED,
II_WEIGHTS_1D, INTRA_FRAME, LAST_FRAME, LOCALWARP, LS_MV_MAX, MASK_MASTER_SIZE, MAX_FRAME_DISTANCE, MAX_SB_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2,
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, OBMC_MASK_2, OBMC_MASK_4, OBMC_MASK_8, OBMC_MASK_16, OBMC_MASK_32, QUANT_DIST_LOOKUP,
QUANT_DIST_WEIGHT, REF_SCALE_SHIFT, SCALE_SUBPEL_BITS, SUBPEL_BITS, SUBPEL_FILTERS, SUBPEL_MASK, TRANSLATION, WARP_PARAM_REDUCE_BITS,
WARPED_FILTERS, WARPEDDIFF_PREC_BITS, WARPEDMODEL_NONDIAGAFFINE_CLAMP, WARPEDMODEL_PREC_BITS, WARPEDMODEL_TRANS_CLAMP, WARPEDPIXEL_PREC_SHIFTS,
WEDGE_BITS, WEDGE_CODEBOOK, WEDGE_HORIZONTAL, WEDGE_MASTER_OBLIQUE_EVEN, WEDGE_MASTER_OBLIQUE_ODD, WEDGE_MASTER_VERTICAL, WEDGE_OBLIQUE27,
WEDGE_OBLIQUE63, WEDGE_OBLIQUE117, WEDGE_OBLIQUE153, WEDGE_TYPES, WEDGE_VERTICAL,
};
use crate::tile::TileDecoder;
const PRED_STRIDE: usize = 128;
@@ -678,7 +687,7 @@ fn hpass<const W: usize>(src: &[u16], hf: &[i16; 8], out: &mut [i32], half: i32,
let mut acc = [half; W];
for t in 0..8 {
let f = hf[t] as i32;
let Some(p) = src.get(t..).and_then(|s| s.first_chunk::<W>()) else {
let Some(p) = src.get(t..).and_then(<[_]>::first_chunk::<W>) else {
return;
};
for j in 0..W {
@@ -699,7 +708,7 @@ fn vpass<const W: usize>(rows: &[i32], vf: &[i16; 8], out: &mut [i32], half: i32
let mut acc = [half; W];
for t in 0..8 {
let f = vf[t] as i32;
let Some(p) = rows.get(t * W..).and_then(|s| s.first_chunk::<W>()) else {
let Some(p) = rows.get(t * W..).and_then(<[_]>::first_chunk::<W>) else {
return;
};
for j in 0..W {
+8 -1
View File
@@ -3,7 +3,14 @@
use crate::Result;
use crate::header::{EIGHTTAP, SWITCHABLE};
use crate::mvpred::MvP;
use crate::spec_tables::*;
use crate::spec_tables::{
ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_32X32, BLOCK_64X64, BLOCK_128X128, BWDREF_FRAME, CLASS0_SIZE, COMP_NEWMV_CTXS, COMPOUND_AVERAGE,
COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_MODE_CTX_MAP, COMPOUND_REFERENCE, COMPOUND_WEDGE, GLOBAL_GLOBALMV, GLOBALMV,
GOLDEN_FRAME, INTRA_FRAME, INTRABC_DELAY_PIXELS, LAST_FRAME, LAST2_FRAME, LAST3_FRAME, LOCALWARP, MV_CLASS_0, MV_INTRABC_CONTEXT,
MV_JOINT_HNZVNZ, MV_JOINT_HNZVZ, MV_JOINT_HZVNZ, NEAR_NEARMV, NEAR_NEWMV, NEAREST_NEARESTMV, NEAREST_NEWMV, NEARESTMV, NEARMV, NEW_NEARESTMV,
NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, REF_SCALE_SHIFT, SEG_LVL_GLOBALMV, SEG_LVL_REF_FRAME, SEG_LVL_SKIP,
SIMPLE, SINGLE_REFERENCE, SIZE_GROUP, TRANSLATION, UNIDIR_COMP_REFERENCE, UV_CFL_PRED, WEDGE_BITS,
};
use crate::tile::TileDecoder;
fn block_width(bs: usize) -> usize {
+5 -1
View File
@@ -1,7 +1,11 @@
//! Intra prediction (spec 7.11.2), palette prediction (7.11.4) and chroma from luma (7.11.5).
use crate::frame::Plane;
use crate::spec_tables::*;
use crate::spec_tables::{
ANGLE_STEP, D67_PRED, DC_PRED, DR_INTRA_DERIVATIVE, INTRA_EDGE_KERNEL, INTRA_EDGE_TAPS, INTRA_FILTER_SCALE_BITS, INTRA_FILTER_TAPS,
MODE_TO_ANGLE, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SM_WEIGHTS_TX_64X64, SMOOTH_H_PRED, SMOOTH_PRED,
SMOOTH_V_PRED, V_PRED,
};
use crate::transform::round2;
/// Inputs of the intra prediction process that come from the block.
+4 -1
View File
@@ -1,7 +1,10 @@
//! Motion vector prediction processes (spec 7.10): find_mv_stack, has_overlappable_candidates
//! and find_warp_samples.
use crate::spec_tables::*;
use crate::spec_tables::{
GLOBAL_GLOBALMV, GLOBALMV, IDENTITY, INTRA_FRAME, LEAST_SQUARES_SAMPLES_MAX, MAX_REF_MV_STACK_SIZE, MV_BORDER, NEAR_NEWMV, NEAREST_NEWMV,
NEW_NEARESTMV, NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, REF_CAT_LEVEL, TRANSLATION, WARPEDMODEL_PREC_BITS,
};
use crate::tile::TileDecoder;
pub(crate) type MvP = [i32; 2];
+4 -1
View File
@@ -1,7 +1,10 @@
//! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15),
//! super-resolution upscaling (7.16) and loop restoration (7.17).
use crate::spec_tables::*;
use crate::spec_tables::{
GLOBAL_GLOBALMV, GLOBALMV, INTRA_FRAME, MAX_LOOP_FILTER, NEARESTMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SEG_LVL_ALT_LF_Y_V, TX_HEIGHT,
TX_WIDTH,
};
use crate::state::FrameState;
use crate::stats::{DecodeStats, Stage, Timer};
+5 -1
View File
@@ -1,7 +1,11 @@
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
use crate::frame::{FrameBuf, Plane};
use crate::spec_tables::*;
use crate::spec_tables::{
FILTER_BITS, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER, SGR_PARAMS, SGRPROJ_MTABLE_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_RECIP_BITS,
SGRPROJ_RST_BITS, SGRPROJ_SGR_BITS, SUPERRES_EXTRA_BITS, SUPERRES_FILTER_OFFSET, SUPERRES_FILTER_TAPS, SUPERRES_SCALE_BITS, SUPERRES_SCALE_MASK,
UPSCALE_FILTER,
};
use crate::state::FrameState;
/// Upscale a frame horizontally from FrameWidth to UpscaledWidth (7.16).
+1 -1
View File
@@ -9,7 +9,7 @@
use crate::decoder::RefFrame;
use crate::frame::{FrameBuf, MiInfo};
use crate::header::{FrameHeader, RefInfo, SequenceHeader};
use crate::spec_tables::*;
use crate::spec_tables::{DEFAULT_INTRA_FRAME_Y_MODE_CDF, NUM_REF_FRAMES, RESTORE_NONE, SUBSAMPLED_SIZE};
use std::ops::{Deref, DerefMut};
use std::sync::Arc;
+23 -5
View File
@@ -6,7 +6,27 @@ use crate::cdf::Cdfs;
use crate::frame::{MiInfo, Mv};
use crate::header::{FrameHeader, NONE, SequenceHeader};
use crate::intra::{IntraParams, is_directional_mode, predict_intra, round2_signed};
use crate::spec_tables::*;
use crate::spec_tables::{
AC_QLOOKUP, ADJUSTED_TX_SIZE, ADST_FLIPADST, BLOCK_4X4, BLOCK_8X8, BLOCK_64X64, BLOCK_128X128, BR_CDF_SIZE, CFL_SIGN_NEG, CFL_SIGN_ZERO,
COEFF_BASE_CTX_OFFSET, COEFF_BASE_POS_CTX_OFFSET, COEFF_BASE_RANGE, COMPOUND_AVERAGE, DC_PRED, DC_QLOOKUP, DCT_DCT, DCT_FLIPADST,
DEFAULT_SCAN_4X4, DEFAULT_SCAN_4X8, DEFAULT_SCAN_4X16, DEFAULT_SCAN_8X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_8X16, DEFAULT_SCAN_8X32,
DEFAULT_SCAN_16X4, DEFAULT_SCAN_16X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_16X32, DEFAULT_SCAN_32X8, DEFAULT_SCAN_32X16, DEFAULT_SCAN_32X32,
DELTA_LF_SMALL, DELTA_Q_SMALL, FILTER_INTRA_MODE_TO_INTRA_DIR, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, FRAME_LF_COUNT, H_ADST, H_DCT,
H_FLIPADST, H_PRED, IDTX, II_DC_PRED, II_H_PRED, II_V_PRED, INTRA_FRAME, INTRA_MODE_CONTEXT, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA,
MAX_LOOP_FILTER, MAX_TX_DEPTH_TABLE, MAX_TX_SIZE_RECT, MAX_VARTX_DEPTH, MCOL_SCAN_4X4, MCOL_SCAN_4X8, MCOL_SCAN_4X16, MCOL_SCAN_8X4,
MCOL_SCAN_8X8, MCOL_SCAN_8X16, MCOL_SCAN_16X4, MCOL_SCAN_16X8, MCOL_SCAN_16X16, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MODE_TO_TXFM, MROW_SCAN_4X4,
MROW_SCAN_4X8, MROW_SCAN_4X16, MROW_SCAN_8X4, MROW_SCAN_8X8, MROW_SCAN_8X16, MROW_SCAN_16X4, MROW_SCAN_16X8, MROW_SCAN_16X16,
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PALETTE_COLOR_CONTEXT, PALETTE_COLOR_HASH_MULTIPLIERS, PALETTE_COLORS,
PALETTE_NUM_NEIGHBORS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE, PARTITION_SPLIT, PARTITION_SUBSIZE,
PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, QM_OFFSET, QUANTIZER_MATRIX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER,
SEG_LVL_SKIP, SGR_PARAMS, SGRPROJ_PARAMS_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_PRJ_SUBEXP_K, SIG_REF_DIFF_OFFSET, SIG_REF_DIFF_OFFSET_NUM, SIMPLE,
SMOOTH_H_PRED, SMOOTH_PRED, SMOOTH_V_PRED, SPLIT_TX_SIZE, SUPERRES_NUM, TX_4X4, TX_4X8, TX_4X16, TX_8X4, TX_8X8, TX_8X16, TX_8X32, TX_16X4,
TX_16X8, TX_16X16, TX_16X32, TX_16X64, TX_32X8, TX_32X16, TX_32X32, TX_32X64, TX_64X16, TX_64X32, TX_64X64, TX_CLASS_2D, TX_CLASS_HORIZ,
TX_CLASS_VERT, TX_HEIGHT, TX_HEIGHT_LOG2, TX_MODE_SELECT, TX_SET_DCTONLY, TX_SET_INTER_1, TX_SET_INTER_2, TX_SET_INTER_3, TX_SET_INTRA_1,
TX_SET_INTRA_2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_SIZES, TX_SIZES_ALL, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA, TX_TYPE_INTER_INV_SET1,
TX_TYPE_INTER_INV_SET2, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET1, TX_TYPE_INTRA_INV_SET2, TX_WIDTH, TX_WIDTH_LOG2, UV_CFL_PRED, V_ADST,
V_DCT, V_FLIPADST, V_PRED,
};
use crate::state::{FrameShared, TileState};
use crate::symbol::SymbolDecoder;
use crate::transform::inverse_transform_2d;
@@ -2222,18 +2242,16 @@ fn neg_deinterleave(diff: i32, r: i32, max: i32) -> i32 {
if diff <= 2 * r {
if diff & 1 == 1 {
return r + ((diff + 1) >> 1);
} else {
return r - (diff >> 1);
}
return r - (diff >> 1);
}
diff
} else {
if diff <= 2 * (max - r - 1) {
if diff & 1 == 1 {
return r + ((diff + 1) >> 1);
} else {
return r - (diff >> 1);
}
return r - (diff >> 1);
}
max - (diff + 1)
}
+4 -1
View File
@@ -1,6 +1,9 @@
//! Inverse transforms (spec 7.13), implemented step by step as specified.
use crate::spec_tables::*;
use crate::spec_tables::{
ADST_ADST, ADST_DCT, ADST_FLIPADST, COS128_LOOKUP, DCT_ADST, DCT_DCT, DCT_FLIPADST, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, H_ADST,
H_DCT, H_FLIPADST, TRANSFORM_ROW_SHIFT, TX_HEIGHT_LOG2, TX_WIDTH_LOG2, V_ADST, V_DCT, V_FLIPADST,
};
#[inline(always)]
fn round2_64(x: i64, n: u32) -> i32 {
+1 -3
View File
@@ -9,6 +9,4 @@ rust-version.workspace = true
workspace = true
[dependencies]
[dev-dependencies]
proptest = { workspace = true }
craft-media = { workspace = true, features = ["bitstream"] }
+4 -485
View File
@@ -1,495 +1,14 @@
//! Bit-level reading and writing shared by DeckCraft's codec crates (ported from FilmCraft).
//! Bit-level reading and writing for DeckCraft's codec crates: a re-export of
//! [`craft_media::bitstream`] from craft-libs, plus [`budget`].
//!
//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers.
//! - [`BitWriter`]: the matching writer (for encoders and header rewriting).
//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes.
//! - [`budget`]: the memory budget video decoders check their picture store against.
//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
//! - [`budget`]: the memory budget video decoders check their picture store against.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
use std::fmt;
pub use craft_media::bitstream::*;
pub mod budget;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BitError {
/// Tried to read past the end of the buffer.
Eof,
/// An Exp-Golomb code longer than 32 leading zeros.
InvalidExpGolomb,
/// A NAL length prefix width outside 1..=4 bytes.
InvalidLengthSize,
}
impl fmt::Display for BitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BitError::Eof => f.write_str("unexpected end of bitstream"),
BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"),
BitError::InvalidLengthSize => f.write_str("unsupported NAL length prefix size"),
}
}
}
impl std::error::Error for BitError {}
pub type Result<T> = std::result::Result<T, BitError>;
/// MSB-first bit reader over a byte slice.
#[derive(Clone)]
pub struct BitReader<'a> {
data: &'a [u8],
/// Position in bits.
pos: usize,
}
impl<'a> BitReader<'a> {
pub fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
pub fn data(&self) -> &'a [u8] {
self.data
}
/// Bit position from the start.
pub fn position(&self) -> usize {
self.pos
}
pub fn bits_left(&self) -> usize {
(self.data.len() * 8).saturating_sub(self.pos)
}
pub fn is_byte_aligned(&self) -> bool {
self.pos.is_multiple_of(8)
}
pub fn byte_align(&mut self) {
self.pos = self.pos.div_ceil(8) * 8;
}
/// Byte offset of the current position (rounded down).
pub fn byte_pos(&self) -> usize {
self.pos / 8
}
pub fn seek_bits(&mut self, pos: usize) {
self.pos = pos;
}
/// Peek up to 32 bits without consuming (zero-padded past the end).
#[inline]
pub fn peek(&self, n: u32) -> u32 {
debug_assert!(n <= 32);
if n == 0 {
return 0;
}
let byte = self.pos / 8;
let shift = self.pos % 8;
let mut v: u64 = 0;
for i in 0..5 {
v = (v << 8) | *self.data.get(byte + i).unwrap_or(&0) as u64;
}
// v holds 40 bits starting at `byte`
((v << (24 + shift)) >> (64 - n)) as u32
}
#[inline]
pub fn read_bits(&mut self, n: u32) -> Result<u32> {
if n as usize > self.bits_left() {
return Err(BitError::Eof);
}
let v = self.peek(n);
self.pos += n as usize;
Ok(v)
}
pub fn read_bits_u64(&mut self, n: u32) -> Result<u64> {
if n <= 32 {
return self.read_bits(n).map(u64::from);
}
let hi = self.read_bits(n - 32)? as u64;
let lo = self.read_bits(32)? as u64;
Ok((hi << 32) | lo)
}
#[inline]
pub fn read_bit(&mut self) -> Result<bool> {
self.read_bits(1).map(|b| b == 1)
}
pub fn read_flag(&mut self) -> Result<bool> {
self.read_bit()
}
pub fn skip(&mut self, n: usize) -> Result<()> {
if n > self.bits_left() {
return Err(BitError::Eof);
}
self.pos += n;
Ok(())
}
/// Unsigned Exp-Golomb `ue(v)`.
pub fn read_ue(&mut self) -> Result<u32> {
let mut zeros = 0u32;
while !self.read_bit()? {
zeros += 1;
if zeros > 32 {
return Err(BitError::InvalidExpGolomb);
}
}
if zeros == 0 {
return Ok(0);
}
let v = self.read_bits_u64(zeros)?;
let r = (1u64 << zeros) - 1 + v;
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
}
/// `ue(v) + add` (the `..._minus1` / `..._minus8` fields), failing instead of overflowing on a
/// hostile value near `u32::MAX`.
pub fn read_ue_plus(&mut self, add: u32) -> Result<u32> {
self.read_ue()?.checked_add(add).ok_or(BitError::InvalidExpGolomb)
}
/// Signed Exp-Golomb `se(v)`. Codes whose value does not fit an `i32` (the code for `2^31`) are rejected.
pub fn read_se(&mut self) -> Result<i32> {
let k = self.read_ue()? as i64;
let v = if k & 1 == 1 { (k + 1) / 2 } else { -(k / 2) };
i32::try_from(v).map_err(|_| BitError::InvalidExpGolomb)
}
/// Truncated Exp-Golomb `te(v)` with range `max`.
pub fn read_te(&mut self, max: u32) -> Result<u32> {
if max > 1 { self.read_ue() } else { Ok(!self.read_bit()? as u32) }
}
/// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits.
pub fn more_rbsp_data(&self) -> bool {
let total = self.data.len() * 8;
if self.pos >= total {
return false;
}
// find last set bit (the rbsp_stop_one_bit)
let mut last = None;
for (i, &b) in self.data.iter().enumerate().rev() {
if b != 0 {
last = Some(i * 8 + 7 - b.trailing_zeros() as usize);
break;
}
}
match last {
Some(stop) => self.pos < stop,
None => false,
}
}
}
/// MSB-first bit writer.
#[derive(Default, Clone)]
pub struct BitWriter {
buf: Vec<u8>,
acc: u64,
nbits: u32,
}
impl BitWriter {
pub fn new() -> Self {
Self::default()
}
pub fn write_bits(&mut self, value: u32, n: u32) {
debug_assert!(n <= 32);
if n == 0 {
return;
}
let v = (value as u64) & ((1u64 << n) - 1);
self.acc = (self.acc << n) | v;
self.nbits += n;
while self.nbits >= 8 {
self.nbits -= 8;
self.buf.push((self.acc >> self.nbits) as u8);
}
self.acc &= (1u64 << self.nbits) - 1;
}
pub fn write_bit(&mut self, b: bool) {
self.write_bits(b as u32, 1);
}
pub fn write_ue(&mut self, v: u32) {
self.write_ue_u64(v as u64);
}
fn write_ue_u64(&mut self, v: u64) {
let x = v + 1;
let len = 64 - x.leading_zeros();
let zeros = len - 1;
for _ in 0..zeros {
self.write_bit(false);
}
if len > 32 {
self.write_bits((x >> 32) as u32, len - 32);
self.write_bits(x as u32, 32);
} else {
self.write_bits(x as u32, len);
}
}
/// Signed Exp-Golomb `se(v)`. `i32::MIN` needs a code above `u32::MAX`, which `read_se` rejects.
pub fn write_se(&mut self, v: i32) {
let v = v as i64;
let k = if v > 0 { (v as u64) * 2 - 1 } else { ((-v) as u64) * 2 };
self.write_ue_u64(k);
}
pub fn bit_len(&self) -> usize {
self.buf.len() * 8 + self.nbits as usize
}
pub fn is_byte_aligned(&self) -> bool {
self.nbits == 0
}
/// Pad with zero bits to a byte boundary.
pub fn align_zero(&mut self) {
if self.nbits > 0 {
self.write_bits(0, 8 - self.nbits);
}
}
/// `rbsp_trailing_bits()`: a one bit then zeros to alignment.
pub fn rbsp_trailing(&mut self) {
self.write_bit(true);
self.align_zero();
}
pub fn write_bytes(&mut self, bytes: &[u8]) {
if self.nbits == 0 {
self.buf.extend_from_slice(bytes);
} else {
for &b in bytes {
self.write_bits(b as u32, 8);
}
}
}
pub fn finish(mut self) -> Vec<u8> {
self.align_zero();
self.buf
}
pub fn bytes(&self) -> &[u8] {
&self.buf
}
}
/// Remove emulation-prevention bytes (`00 00 03` → `00 00`) from a NAL payload.
pub fn unescape_rbsp(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len());
let mut zeros = 0;
for &b in data {
if zeros >= 2 && b == 3 {
zeros = 0;
continue;
}
out.push(b);
zeros = if b == 0 { zeros + 1 } else { 0 };
}
out
}
/// Insert emulation-prevention bytes so no `00 00 0x` (x ≤ 3) sequence appears.
pub fn escape_rbsp(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len() + data.len() / 64);
let mut zeros = 0;
for &b in data {
if zeros >= 2 && b <= 3 {
out.push(3);
zeros = 0;
}
out.push(b);
zeros = if b == 0 { zeros + 1 } else { 0 };
}
out
}
/// Split an Annex-B byte stream (start codes `00 00 01` / `00 00 00 01`) into NAL units (escaped payloads).
pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> {
let mut starts = Vec::new();
let mut i = 0;
while i + 3 <= data.len() {
if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
starts.push(i + 3);
i += 3;
} else {
i += 1;
}
}
let mut out = Vec::with_capacity(starts.len());
for (k, &s) in starts.iter().enumerate() {
let mut e = if k + 1 < starts.len() { starts[k + 1] - 3 } else { data.len() };
// trim trailing zero bytes (part of a 4-byte start code or trailing_zero_8bits)
while e > s && data[e - 1] == 0 {
e -= 1;
}
if e > s {
out.push(&data[s..e]);
}
}
out
}
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1..=4 bytes, as the 2-bit `lengthSizeMinusOne` allows) into NAL units.
pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result<Vec<&[u8]>> {
if !(1..=4).contains(&length_size) {
return Err(BitError::InvalidLengthSize);
}
let mut out = Vec::new();
let mut i = 0;
while i < data.len() {
if length_size > data.len() - i {
return Err(BitError::Eof);
}
let mut len = 0usize;
for k in 0..length_size {
len = (len << 8) | data[i + k] as usize;
}
i += length_size;
if len > data.len() - i {
return Err(BitError::Eof);
}
out.push(&data[i..i + len]);
i += len;
}
Ok(out)
}
/// CRC-32 (IEEE, reflected) — used by containers (e.g. Matroska CRC-32 elements, PNG).
pub fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for &b in data {
crc ^= b as u32;
for _ in 0..8 {
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
}
}
!crc
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn read_ue_plus_rejects_overflow() {
let mut w = BitWriter::new();
w.write_ue(7);
w.write_ue(u32::MAX);
w.rbsp_trailing();
let b = w.finish();
let mut r = BitReader::new(&b);
assert_eq!(r.read_ue_plus(1).unwrap(), 8);
assert!(r.read_ue_plus(1).is_err());
}
#[test]
fn signed_exp_golomb_extremes() {
let mut w = BitWriter::new();
for v in [i32::MAX, i32::MIN + 1, i32::MIN] {
w.write_se(v);
}
w.rbsp_trailing();
let b = w.finish();
let mut r = BitReader::new(&b);
assert_eq!(r.read_se().unwrap(), i32::MAX);
assert_eq!(r.read_se().unwrap(), i32::MIN + 1);
assert_eq!(r.read_se(), Err(BitError::InvalidExpGolomb));
// ue code 0xFFFF_FFFF maps to +2^31, which must not wrap to a negative i32.
let mut w = BitWriter::new();
w.write_ue(u32::MAX);
w.rbsp_trailing();
let b = w.finish();
assert_eq!(BitReader::new(&b).read_se(), Err(BitError::InvalidExpGolomb));
}
#[test]
fn length_prefixed_rejects_bad_width_and_oversized_length() {
assert_eq!(length_prefixed_nals(&[1, 2, 3, 4, 5, 6], 5), Err(BitError::InvalidLengthSize));
assert_eq!(length_prefixed_nals(&[1, 2, 3], 0), Err(BitError::InvalidLengthSize));
assert_eq!(length_prefixed_nals(&[0, 0, 1, 7], 3).unwrap(), vec![&[7u8][..]]);
assert_eq!(length_prefixed_nals(&[0xFF, 0xFF, 0xFF, 0xFF, 1], 4), Err(BitError::Eof));
assert_eq!(length_prefixed_nals(&[0, 0, 0, 1, 9], 4).unwrap(), vec![&[9u8][..]]);
}
#[test]
fn reads_bits() {
let d = [0b1010_1100, 0b0101_0011];
let mut r = BitReader::new(&d);
assert_eq!(r.read_bits(1).unwrap(), 1);
assert_eq!(r.read_bits(3).unwrap(), 0b010);
assert_eq!(r.read_bits(8).unwrap(), 0b1100_0101);
assert_eq!(r.bits_left(), 4);
assert_eq!(r.read_bits(5), Err(BitError::Eof));
}
#[test]
fn exp_golomb_known() {
// 1 | 010 | 011 | 00100 | 00101 -> 0,1,2,3,4
let mut w = BitWriter::new();
for v in 0..5 {
w.write_ue(v);
}
let b = w.finish();
let mut r = BitReader::new(&b);
for v in 0..5 {
assert_eq!(r.read_ue().unwrap(), v);
}
let mut w = BitWriter::new();
for v in [0, 1, -1, 2, -2, 1000, -1000] {
w.write_se(v);
}
let b = w.finish();
let mut r = BitReader::new(&b);
for v in [0, 1, -1, 2, -2, 1000, -1000] {
assert_eq!(r.read_se().unwrap(), v);
}
}
#[test]
fn emulation_prevention() {
let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3];
let esc = escape_rbsp(&raw);
assert_eq!(esc, vec![0, 0, 3, 1, 0, 0, 3, 0, 5, 0, 0, 3, 3]);
assert_eq!(unescape_rbsp(&esc), raw);
}
#[test]
fn annexb_split() {
let s = [0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3, 0, 0, 0, 1, 0x65, 9, 9];
let n = annexb_nals(&s);
assert_eq!(n, vec![&[0x67, 1, 2][..], &[0x68, 3][..], &[0x65, 9, 9][..]]);
let l = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 1, 0x68];
assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
}
#[test]
fn more_rbsp() {
let d = [0b1100_0000];
let mut r = BitReader::new(&d);
assert!(r.more_rbsp_data());
r.read_bit().unwrap();
assert!(!r.more_rbsp_data());
}
#[test]
fn crc() {
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
}
proptest! {
#[test]
fn roundtrip(vals in proptest::collection::vec((0u32..u32::MAX, 1u32..=32), 0..200), ues in proptest::collection::vec(0u32..u32::MAX - 1, 0..50)) {
let mut w = BitWriter::new();
for &(v, n) in &vals { w.write_bits(v, n); }
for &u in &ues { w.write_ue(u); }
let b = w.finish();
let mut r = BitReader::new(&b);
for &(v, n) in &vals {
let mask = if n == 32 { u32::MAX } else { (1 << n) - 1 };
prop_assert_eq!(r.read_bits(n).unwrap(), v & mask);
}
for &u in &ues { prop_assert_eq!(r.read_ue().unwrap(), u); }
}
#[test]
fn escape_roundtrip(data in proptest::collection::vec(0u8..4, 0..300)) {
let e = escape_rbsp(&data);
prop_assert_eq!(unescape_rbsp(&e), data);
for w in e.windows(3) { prop_assert!(!(w[0] == 0 && w[1] == 0 && w[2] < 3)); }
}
}
}
+9 -1
View File
@@ -35,7 +35,7 @@ impl Rgba {
/// `RRGGBB` or `#RRGGBB` (also `RRGGBBAA`).
pub fn from_hex(s: &str) -> Option<Self> {
let s = s.trim().trim_start_matches('#');
if !s.is_ascii() {
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
@@ -519,6 +519,14 @@ pub fn preset(name: &str) -> Option<Rgba> {
mod tests {
use super::*;
#[test]
fn from_hex_rejects_signs() {
assert_eq!(Rgba::from_hex("+12345"), None);
assert_eq!(Rgba::from_hex("#+1+2+3"), None);
assert_eq!(Rgba::from_hex("+1+2+3+4"), None);
assert_eq!(Rgba::from_hex("+12"), None);
}
#[test]
fn hex_roundtrip() {
assert_eq!(Rgba::from_hex("#156082"), Some(Rgba::rgb(0x15, 0x60, 0x82)));
+1
View File
@@ -25,6 +25,7 @@ i_overlay = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
image = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
deckcraft-anim = { workspace = true }
+42
View File
@@ -525,4 +525,46 @@ mod tests {
s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap();
assert!(s.anim_painter.is_some());
}
fn setup_triggers() -> (Session, [u64; 4]) {
let mut s = Session::with_new();
let mut ids = [0u64; 4];
for (slot, x) in ids.iter_mut().zip([10.0, 150.0, 300.0, 450.0]) {
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10.0, 100.0, 100.0]})).unwrap();
*slot = r.get("id").unwrap().as_u64().unwrap();
}
(s, ids)
}
fn triggers(s: &Session) -> Vec<Option<deckcraft_model::ShapeId>> {
s.doc().unwrap().current_slide().unwrap().animations.iter().map(|x| x.trigger).collect()
}
#[test]
fn deleting_a_trigger_shape_clears_the_trigger() {
let (mut s, [a, b, c, d]) = setup_triggers();
let id = |v: u64| Some(deckcraft_model::ShapeId(v as u32));
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
s.execute("animation.add", &json!({"effect": "fade", "ids": [d]})).unwrap();
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
s.execute("animation.timing", &json!({"index": 1, "trigger": c})).unwrap();
assert_eq!(triggers(&s), vec![id(b), id(c)]);
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
assert_eq!(triggers(&s), vec![None, id(c)], "only the deleted trigger is cleared");
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 2, "the animated shapes keep their effects");
}
#[test]
fn deleting_a_group_clears_triggers_of_its_children() {
let (mut s, [a, b, c, _]) = setup_triggers();
let g = s.execute("arrange.group", &json!({"ids": [b, c]})).unwrap().get("id").unwrap().as_u64().unwrap();
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
assert_eq!(triggers(&s), vec![Some(deckcraft_model::ShapeId(b as u32))]);
s.execute("edit.delete", &json!({"ids": [g]})).unwrap();
assert_eq!(triggers(&s), vec![None], "the trigger was inside the deleted group");
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 1);
}
}
+23 -4
View File
@@ -171,6 +171,22 @@ pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
Some(it.fold(first, |a, b| a.union(b)))
}
/// How deep groups may nest: as deep as the PPTX reader reads and the renderer draws. A saved
/// .deckcraft file stops opening at about 39 (the JSON reader's recursion limit), and walks over
/// the shape tree stop at 64.
pub const MAX_GROUP_DEPTH: usize = 32;
/// How many groups deep `shapes` nest (0 without groups).
fn group_depth(shapes: &[Shape]) -> usize {
let mut d = 0;
deckcraft_model::walk(shapes, &mut |sh, depth| {
if sh.is_group() {
d = d.max(depth + 1);
}
});
d
}
fn group(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|doc, sel| {
@@ -179,6 +195,9 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
if members.len() < 2 {
return Err(bad("arrange.group", "select two or more objects on the slide"));
}
if group_depth(&members) + 1 > MAX_GROUP_DEPTH {
return Err(bad("arrange.group", format!("groups can be nested at most {MAX_GROUP_DEPTH} deep")));
}
for m in &mut members {
if m.xfrm.is_none() {
m.xfrm = Some(xfrm_of(doc, sel, m));
@@ -208,7 +227,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
}
/// A child's xfrm in slide space, given its group.
pub(crate) fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
pub fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 };
let sy = if ch.h.abs() > 1e-9 { group.h / ch.h } else { 1.0 };
let a = deckcraft_geom::group_child_affine(group, Rect::new(ch.x, ch.y, ch.x + ch.w, ch.y + ch.h));
@@ -322,9 +341,9 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let (start, end) = if to_slide {
if horiz { (0.0, size.width) } else { (0.0, size.height) }
} else {
let f = boxes.first().map(|b| b.1).unwrap_or_default();
let l = boxes.last().map(|b| b.1).unwrap_or_default();
if horiz { (f.x0, l.x1) } else { (f.y0, l.y1) }
let lo = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::INFINITY, f64::min);
let hi = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::NEG_INFINITY, f64::max);
if horiz { (lo(|r| r.x0), hi(|r| r.x1)) } else { (lo(|r| r.y0), hi(|r| r.y1)) }
};
let n = boxes.len();
let gap = if n > 1 { ((end - start) - total) / (n - 1) as f64 } else { 0.0 };
+527 -22
View File
@@ -1,6 +1,7 @@
//! Design tab and masters: themes, colours, fonts, backgrounds, slide size, header & footer,
//! Slide Master view.
use std::collections::BTreeSet;
use std::sync::Arc;
use deckcraft_geom::Size;
@@ -20,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Browse for Themes…",
["Design", "Themes"],
None,
"{path} | {name, data: base64} — apply the theme of a .thmx, .pptx or .potx file",
"{path} | {name, data: base64} — apply a .pptx or .potx's slide master and layouts (slides move to the matching layouts), or a .thmx's theme",
has_doc,
browse_theme
),
@@ -160,6 +161,13 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
if !deckcraft_pptx::sniff(&bytes) {
return Err(bad("design.browseTheme", "not a theme or PowerPoint file"));
}
// A presentation or template brings its slide master and layouts, as in PowerPoint; a .thmx
// (its main part is a theme, not a presentation) brings the theme alone.
if let Ok(src) = super::file::open_presentation("theme.pptx", &bytes)
&& src.masters.first().is_some_and(|m| !m.layouts.is_empty())
{
return apply_master(s, &src);
}
let (theme, media) = deckcraft_pptx::import_theme(&bytes).map_err(|e| bad("design.browseTheme", e.to_string()))?;
let name = theme.name.clone();
s.edit(|doc, sel| {
@@ -179,6 +187,58 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"theme": name}))
}
/// Design › Browse for Themes… with a `.pptx` / `.potx`: its first master and layouts replace the
/// master the current slide uses (a preserved master stays beside it), stretched to this deck's
/// slide size. Each slide on it moves to the new layout with the same name, else the same kind,
/// keeping its content as Layout does.
fn apply_master(s: &mut Session, src: &Presentation) -> Result<Value> {
let from = src.masters.first().ok_or_else(|| bad("design.browseTheme", "no master"))?;
let theme = from.theme.name.clone();
s.edit(|doc, sel| {
let mi = master_index(doc, sel);
let old = doc.masters.get(mi).cloned().ok_or_else(|| bad("design.browseTheme", "no master"))?;
let (mut media, mut layouts) = Default::default();
let mut m = super::reuse::import_master("design.browseTheme", doc, src, from, &mut media, &mut layouts)?;
let (sx, sy) = (doc.slide_size.width / src.slide_size.width.max(1.0), doc.slide_size.height / src.slide_size.height.max(1.0));
if (sx - 1.0).abs() > 1e-9 || (sy - 1.0).abs() > 1e-9 {
scale_shapes(&mut m.shapes, sx, sy, 1.0, 0.0, 0.0);
for l in &mut m.layouts {
scale_shapes(&mut l.shapes, sx, sy, 1.0, 0.0, 0.0);
}
}
let moves: Vec<(usize, deckcraft_model::LayoutId)> =
doc.slides.iter().enumerate().filter_map(|(i, sl)| old.layout(sl.layout).and_then(|l| matching_layout(&m, l)).map(|l| (i, l))).collect();
let layouts = m.layouts.len();
if old.preserve {
doc.masters.insert(mi, Arc::new(m));
} else if let Some(slot) = doc.masters.get_mut(mi) {
*slot = Arc::new(m);
}
for (i, l) in &moves {
super::slide::apply_layout(doc, *i, *l)?;
}
if let Target::Layout { master, .. } = sel.target {
sel.target = Target::Master { master };
}
sel.shapes.clear();
sel.text = None;
Ok(json!({"theme": theme, "layouts": layouts, "slides": moves.len()}))
})
}
/// The layout of `m` a slide on `from` moves to: same name, else same kind (not for custom
/// layouts), else Title and Content, else the first.
fn matching_layout(m: &deckcraft_model::Master, from: &deckcraft_model::Layout) -> Option<deckcraft_model::LayoutId> {
use deckcraft_model::LayoutType;
let ls = &m.layouts;
ls.iter()
.find(|l| !from.name.is_empty() && l.name.trim().eq_ignore_ascii_case(from.name.trim()))
.or_else(|| ls.iter().find(|l| from.kind != LayoutType::Custom && l.kind == from.kind))
.or_else(|| ls.iter().find(|l| l.kind == LayoutType::TitleAndContent))
.or_else(|| ls.first())
.map(|l| l.id)
}
/// `theme` with every `media` id field rewritten through `map` (format-scheme picture fills).
fn remap_media_ids(theme: &deckcraft_model::theme::Theme, map: &std::collections::HashMap<u32, u32>) -> Result<deckcraft_model::theme::Theme> {
fn rec(v: &mut Value, map: &std::collections::HashMap<u32, u32>, depth: usize) {
@@ -592,6 +652,11 @@ fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option<usize
}
}
fn used_master_indices(doc: &Presentation) -> BTreeSet<usize> {
let fallback = (!doc.masters.is_empty()).then_some(0);
doc.slides.iter().filter_map(|sl| doc.masters.iter().position(|m| m.layout(sl.layout).is_some()).or(fallback)).collect()
}
fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: &Option<Background>) -> bool {
match sel.target {
Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg),
@@ -599,7 +664,16 @@ fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg),
Target::Slides => {
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
!list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg))
let slides_match = !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg));
if all && bg.is_some() {
let masters = used_master_indices(doc);
let masters_match = !masters.is_empty() && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.background == *bg));
let layouts_match = !masters.is_empty()
&& masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.layouts.iter().all(|l| l.background.is_none())));
slides_match && masters_match && layouts_match
} else {
slides_match
}
}
}
}
@@ -622,6 +696,17 @@ fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Optio
}
}
Target::Slides => {
if all && bg.is_some() {
for idx in used_master_indices(doc) {
if let Some(m) = doc.masters.get_mut(idx) {
let m = Arc::make_mut(m);
m.background = bg.clone();
for l in &mut m.layouts {
l.background = None;
}
}
}
}
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
for i in list {
if let Some(sl) = doc.slides.get_mut(i) {
@@ -639,7 +724,14 @@ fn copy_slide_background(s: &mut Session) -> Result<Value> {
return Err(bad("design.background", "copy applies to slides"));
}
let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone());
if st.doc.slides.iter().all(|sl| sl.background == bg) {
let slides_match = st.doc.slides.iter().all(|sl| sl.background == bg);
let masters_match = if bg.is_some() {
let masters = used_master_indices(&st.doc);
!masters.is_empty() && masters.iter().all(|&idx| st.doc.masters.get(idx).is_some_and(|m| m.background == bg))
} else {
true
};
if slides_match && masters_match {
return ok();
}
bg
@@ -821,6 +913,14 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = str_param(p, "dateText") {
hf.date_text = v.into();
}
if let Some(v) = str_param(p, "dateFormat") {
// Refused rather than ignored: a format that renders nothing would leave the
// placeholder showing its saved text for ever.
if !deckcraft_text::datetime::DATE_KINDS.contains(&v) {
return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13"));
}
hf.date_format = v.into();
}
if let Some(v) = bool_param(p, "slideNumber") {
hf.slide_number = v;
}
@@ -836,6 +936,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
}
let hf = hf.clone();
let date_given = str_param(p, "dateText").is_some();
let format_given = str_param(p, "dateFormat").is_some();
let all = bool_or(p, "all", true);
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] };
for i in list {
@@ -880,7 +981,11 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
},
PhType::Date if hf.date_text.is_empty() => TextBody {
paragraphs: vec![deckcraft_model::Paragraph {
runs: vec![Run { text: today(), props: Default::default(), kind: RunKind::Field { field: "datetime1".into() } }],
runs: vec![Run {
text: date_now(&hf.date_format),
props: Default::default(),
kind: RunKind::Field { field: date_kind(&hf.date_format).into() },
}],
..Default::default()
}],
..Default::default()
@@ -901,9 +1006,9 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
sh.text = Some(TextBody::from_text(&hf.footer_text));
}
}
(Some(pos), true) if k == PhType::Date && date_given => {
(Some(pos), true) if k == PhType::Date && (date_given || format_given) => {
if let Some(sh) = sl.shapes.get_mut(pos) {
set_date(sh, &hf.date_text);
set_date(sh, &hf.date_text, &hf.date_format);
}
}
_ => {}
@@ -915,15 +1020,33 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// The date field type `format` names, `datetime1` when it is empty or unknown.
fn date_kind(format: &str) -> &str {
if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" }
}
/// The saved text of a date field of `format`, as it shows now.
fn date_now(format: &str) -> String {
let kind = date_kind(format);
deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today)
}
/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty
/// `fixed`, at an automatic date field (one it already has keeps its format).
fn set_date(sh: &mut Shape, fixed: &str) {
fn set_date(sh: &mut Shape, fixed: &str, format: &str) {
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
let has_field = runs().any(|r| matches!(&r.kind, RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field)));
let field_now = runs().find_map(|r| match &r.kind {
RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()),
_ => None,
});
let has_field = field_now.is_some();
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
let want = date_kind(format);
let (text, kind) = match fixed {
"" if has_field => return,
"" => (today(), RunKind::Field { field: "datetime1".into() }),
// Automatic: keep the field, but follow a format that has changed.
"" if field_now.as_deref() == Some(want) => return,
"" if has_field && format.is_empty() => return,
"" => (date_now(format), RunKind::Field { field: want.into() }),
_ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return,
_ => (fixed.to_string(), RunKind::Text),
};
@@ -943,18 +1066,21 @@ pub fn date_field_text(kind: &str) -> String {
DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default()
}
/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm).
/// Days since 1970-01-01 → (year, month, day), exact for every `i64`.
pub fn civil(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
use chrono::Datelike;
// `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400
// years (146_097 days). chrono converts the day within its cycle and whole cycles go back
// into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and
// 0..146_097 days from it always fits.
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
}
None => (1970, 1, 1),
}
}
fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1456,6 +1582,8 @@ fn slide_number_paragraph() -> deckcraft_model::Paragraph {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use deckcraft_color::SchemeSlot;
use deckcraft_model::style::PictureMode;
use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes};
@@ -1495,6 +1623,11 @@ mod tests {
fn civil_dates() {
assert_eq!(super::civil(0), (1970, 1, 1));
assert_eq!(super::civil(20_734), (2026, 10, 8));
// Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes.
assert_eq!(super::civil(95_026_602), (262_144, 1, 1));
assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1));
assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27));
assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7));
}
#[test]
@@ -1727,12 +1860,195 @@ mod tests {
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step");
}
#[test]
fn background_all_applies_to_master_and_new_slides_inherit() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
let s0 = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
assert_eq!(s0["background"], "#121826");
let n = undo_len(&s);
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// Idempotency: re-applying the same background to all is a no-op (no extra undo step)
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "re-applying already applied background is not an undo step");
// Slide 0 has new background, master has new background, layouts cleared
let doc = &s.doc().unwrap().doc;
assert!(matches!(doc.slides[0].background, Some(Background::Fill { .. })));
assert_eq!(doc.masters[0].background, doc.slides[0].background);
for l in &doc.masters[0].layouts {
assert!(l.background.is_none());
}
// Newly added slides inherit the master background (fixes #891)
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "new"})).unwrap();
let s0_after = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
let s1_after = s.execute("slide.inspect", &json!({"index": 1})).unwrap();
assert_eq!(s0_after["background"], "#0D0D0D");
assert_eq!(s1_after["background"], "#0D0D0D");
// Undo restores previous slide and master backgrounds
s.execute("edit.undo", &json!({})).unwrap(); // undos slide.new
assert_eq!(s.doc().unwrap().doc.slides.len(), 1);
s.execute("edit.undo", &json!({})).unwrap(); // undos design.background
let s0_reverted = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
assert_eq!(s0_reverted["background"], "#121826");
}
#[test]
fn background_copy_from_slide_following_master_preserves_master_and_layout_backgrounds() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x22, 0x22, 0x22))) };
s.edit(|doc, _| {
let m = Arc::make_mut(&mut doc.masters[0]);
m.layouts[0].background = Some(custom_layout_bg.clone());
Ok(())
})
.unwrap();
// Slide 0 follows master (background: None)
assert!(s.doc().unwrap().doc.slides[0].background.is_none());
// Give Slide 1 an explicit override
s.execute("design.background", &json!({"color": "#FF0000", "index": 1})).unwrap();
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
assert!(orig_master_bg.is_some());
// Select slide 0 (which follows master) and apply copy to all
s.execute("slide.go", &json!({"index": 0})).unwrap();
let n = undo_len(&s);
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// Slide 1 has been reset to None (follows master), slide 0 remains None
let doc = &s.doc().unwrap().doc;
assert!(doc.slides[0].background.is_none());
assert!(doc.slides[1].background.is_none());
// Master and layout backgrounds survived!
assert_eq!(doc.masters[0].background, orig_master_bg);
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
// Idempotency: re-applying copy is a no-op
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "re-copying when already matching is a no-op");
}
#[test]
fn background_reset_all_preserves_master_and_layout_backgrounds() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x33, 0x33, 0x33))) };
s.edit(|doc, _| {
let m = Arc::make_mut(&mut doc.masters[0]);
m.layouts[0].background = Some(custom_layout_bg.clone());
Ok(())
})
.unwrap();
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
assert!(orig_master_bg.is_some());
// Set explicit backgrounds on both slides individually (master remains untouched)
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 0})).unwrap();
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 1})).unwrap();
assert!(s.doc().unwrap().doc.slides[0].background.is_some());
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
// Now perform reset with all
let n = undo_len(&s);
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// Slides reset to None to follow master
let doc = &s.doc().unwrap().doc;
assert!(doc.slides[0].background.is_none());
assert!(doc.slides[1].background.is_none());
// Master and layout backgrounds survived!
assert_eq!(doc.masters[0].background, orig_master_bg);
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
// Idempotency: re-running reset all is a no-op
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "re-resetting when already reset is a no-op");
}
#[test]
fn background_all_only_touches_used_masters() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
// Add a second master to presentation
let second_master_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x55, 0x55, 0x55))) };
s.edit(|doc, _| {
let mut m2 = (*doc.masters[0]).clone();
m2.id = deckcraft_model::MasterId(999);
m2.background = Some(second_master_bg.clone());
doc.masters.push(Arc::new(m2));
Ok(())
})
.unwrap();
assert_eq!(s.doc().unwrap().doc.masters.len(), 2);
// Slides only use master 0 (default layout)
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
let doc = &s.doc().unwrap().doc;
// Master 0 updated
assert_eq!(doc.masters[0].background, doc.slides[0].background);
// Master 1 preserved!
assert_eq!(doc.masters[1].background, Some(second_master_bg));
}
#[test]
fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
let mut s = Session::with_new();
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
// Set all slides and master to #AA0000
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(s.doc().unwrap().doc.masters[0].background, s.doc().unwrap().doc.slides[0].background);
// Add a layout override on layout 0
let custom_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56))) };
s.edit(|doc, _| {
let m = Arc::make_mut(&mut doc.masters[0]);
m.layouts[0].background = Some(custom_bg);
Ok(())
})
.unwrap();
// Re-run Apply to All with #AA0000. Must not be skipped as no-op; must clear layout override.
let n = undo_len(&s);
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "should execute and clear layout override, not treat as no-op");
let doc = &s.doc().unwrap().doc;
assert!(doc.masters[0].layouts[0].background.is_none());
// Now that layout override is cleared, repeating is an actual no-op
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
}
}
#[cfg(test)]
mod browse_theme_tests {
use deckcraft_model::PhType;
use std::sync::Arc;
use deckcraft_model::text::RunKind;
use deckcraft_model::{Background, Fill, PhType};
use serde_json::json;
use crate::{Session, Target};
@@ -1758,6 +2074,108 @@ mod browse_theme_tests {
assert!(s.execute("design.browseTheme", &json!({})).is_err());
}
/// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides
/// can only find them by kind) and its content layout's title moved.
fn brand_template() -> (String, Vec<u8>, deckcraft_model::Xfrm) {
let mut img = image::RgbaImage::new(2, 2);
img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255]));
let mut logo = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap();
let mut t = Session::with_new();
t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap();
let title = t
.edit(|doc, _| {
let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap();
let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap());
let m = Arc::make_mut(doc.masters.get_mut(0).unwrap());
m.shapes.push(deckcraft_model::Shape {
id,
name: "Logo".into(),
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }),
..Default::default()
});
m.background =
Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) });
for l in &mut m.layouts {
l.name = format!("Brand {}", l.name);
}
let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap();
let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() };
title.xfrm = Some(x);
Ok(x)
})
.unwrap();
(crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title)
}
#[test]
fn a_template_brings_its_master_and_layouts_and_slides_follow() {
let (data, logo, title) = brand_template();
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap();
let before = s.doc().unwrap().doc.clone();
let n = undo_len(&s);
let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap();
assert_eq!(r["slides"], json!(2), "{r}");
assert_eq!(undo_len(&s), n + 1, "one undo step");
let doc = &s.doc().unwrap().doc;
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
assert_eq!(doc.masters.len(), 1, "the old master is replaced");
let m = &doc.masters[0];
assert_ne!(m.id, before.masters[0].id);
assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand ")));
assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } })));
let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along");
let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") };
assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo));
// The 4:3 master stretches to this deck's 16:9.
let k = doc.slide_size.width / 720.0;
let px = pic.xfrm.unwrap();
assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}");
// Each slide moved to the brand layout of its kind, and kept its text.
for (old, new) in before.slides.iter().zip(&doc.slides) {
let (_, was) = before.layout(old.layout).unwrap();
let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master");
assert_eq!(owner.id, m.id);
assert_eq!(now.kind, was.kind);
}
let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap();
assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one")));
let (_, lay) = doc.layout(agenda.layout).unwrap();
let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap();
assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}");
let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
assert!(st.xfrm.is_none(), "the slide title follows the new layout");
s.execute("edit.undo", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.masters, before.masters);
assert_eq!(doc.slides, before.slides);
}
#[test]
fn a_preserved_master_stays_beside_the_template() {
let (data, _, _) = brand_template();
let mut s = Session::with_new();
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
let old = s.doc().unwrap().doc.masters[0].id;
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.doc.masters.len(), 2);
assert_eq!(st.doc.masters[1].id, old);
assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master");
let new = &st.doc.masters[0];
assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some()));
}
fn undo_len(s: &crate::Session) -> usize {
s.doc().unwrap().history.undo.len()
}
@@ -1908,3 +2326,90 @@ mod browse_theme_tests {
assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01);
}
}
#[cfg(test)]
mod header_footer_date_tests {
use deckcraft_model::PhType;
use deckcraft_model::text::RunKind;
use serde_json::json;
use crate::Session;
/// The date field of the Date placeholders of every slide: (saved text, field type).
fn dates(s: &Session) -> Vec<(String, Option<String>)> {
let d = &s.doc().unwrap().doc;
d.slides
.iter()
.flat_map(|sl| sl.shapes.iter())
.filter(|sh| sh.ph_type() == Some(PhType::Date))
.flat_map(|sh| sh.text.iter())
.flat_map(|t| t.paragraphs.iter())
.flat_map(|p| p.runs.iter())
.map(|r| {
let kind = match &r.kind {
RunKind::Field { field } => Some(field.clone()),
_ => None,
};
(r.text.clone(), kind)
})
.collect()
}
fn sample(kind: &str) -> String {
let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date");
deckcraft_text::datetime::field_text(kind, None, now).expect("a sample")
}
/// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field.
/// The clock is read before and after the command, so a second ticking over between the
/// command and the read cannot fail the comparison. Returns the run text.
fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String {
let before = sample(kind);
s.execute("design.headerFooter", &params).unwrap();
let after = sample(kind);
let got = dates(s);
assert_eq!(got.len(), 1, "{got:?}");
assert_eq!(got[0].1.as_deref(), Some(kind));
assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}");
got[0].0.clone()
}
/// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format`
/// existed for this and was never read: the placeholder was always `datetime1`, and a
/// `dateFormat` parameter was accepted and silently dropped.
#[test]
fn the_automatic_date_takes_the_format_it_is_given() {
let mut s = Session::with_new();
// Default: the short date, as before.
run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1");
// A format: the field type changes and the saved text is that format's.
run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5");
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5");
// A time format works the same way, and these two differ, so the format is really read.
let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
assert_ne!(time, sample("datetime5"));
// Asking again changes nothing (and does not rewrite the run).
let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
assert_eq!(again, time);
// A fixed date still wins: plain text, no field.
s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap();
assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]);
// Back to automatic: the remembered format comes back, not datetime1.
run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11");
// An unknown format is refused rather than ignored, and changes nothing.
for bad in ["datetime0", "datetime14", "nonsense", ""] {
assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}");
}
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11");
let got = dates(&s);
assert_eq!(got.len(), 1, "{got:?}");
assert_eq!(got[0].1.as_deref(), Some("datetime11"));
}
}
+44 -7
View File
@@ -51,6 +51,8 @@ fn restore(s: &mut Session, redo: bool) -> Result<Value> {
st.doc = e.doc;
st.selection = e.selection;
st.interaction = None;
// A later shape can get an undone copy's id: Duplicate starts over with the usual offset.
st.last_duplicate = None;
st.revision += 1;
Ok(json!({"label": e.label}))
}
@@ -175,7 +177,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}))
}
@@ -530,15 +536,31 @@ fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let off = s.prefs.duplicate_offset;
s.edit(|doc, sel| {
let last = s.doc()?.last_duplicate.clone();
let (new_ids, made) = s.edit(|doc, sel| {
let list = super::current_shapes(doc, sel).cloned().unwrap_or_default();
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
let sources: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
// Duplicating the copies of the last Duplicate again repeats how far they were moved
// since (#966); anything else gets the usual offset.
let (dx, dy) = last
.filter(|(slide, target, pairs)| {
*slide == sel.slide && *target == sel.target && pairs.len() == sources.len() && pairs.iter().all(|(_, c)| sources.contains(c))
})
.and_then(|(_, _, pairs)| {
let (src, copy) = pairs.first()?;
let a = xfrm_of(doc, sel, list.iter().find(|x| x.id == *src)?);
let b = xfrm_of(doc, sel, list.iter().find(|x| x.id == *copy)?);
let d = (b.x - a.x, b.y - a.y);
(d.0.is_finite() && d.1.is_finite()).then_some(d)
})
.unwrap_or((off, off));
reid("edit.duplicate", doc, &mut copies)?;
for (c, b) in copies.iter_mut().zip(boxes) {
let mut x = b;
x.x += off;
x.y += off;
x.x += dx;
x.y += dy;
c.xfrm = Some(x);
if sel.target == crate::Target::Slides {
c.ph = None;
@@ -551,8 +573,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?;
shapes.extend(copies);
sel.shapes = new_ids.clone();
Ok(json!({"ids": new_ids}))
})
let made = (sel.slide, sel.target, sources.into_iter().zip(new_ids.iter().copied()).collect());
Ok((new_ids, made))
})?;
s.doc_mut()?.last_duplicate = Some(made);
Ok(json!({"ids": new_ids}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
@@ -568,12 +593,24 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
s.edit(|doc, sel| {
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.delete", "no slide"))?;
let mut before = Vec::new();
deckcraft_model::walk(shapes, &mut |s, _| before.push(s.id));
let n = remove_shapes(shapes, &ids);
// Everything that is gone: the given ids plus group descendants and emptied groups.
let mut left = Vec::new();
deckcraft_model::walk(shapes, &mut |s, _| left.push(s.id));
let mut gone = ids.clone();
gone.extend(before.into_iter().filter(|i| !left.contains(i)));
// Animations of deleted shapes go too.
if sel.target == crate::Target::Slides
&& let Some(sl) = doc.slides.get_mut(sel.slide)
{
Arc::make_mut(sl).animations.retain(|a| !ids.contains(&a.shape));
let sl = Arc::make_mut(sl);
sl.animations.retain(|a| !gone.contains(&a.shape));
// Effects triggered by a deleted shape fall back to the main sequence.
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| gone.contains(&t))) {
a.trigger = None;
}
}
sel.shapes.retain(|i| !ids.contains(i));
sel.text = None;
+129 -32
View File
@@ -1,6 +1,10 @@
//! File menu: new, open, save, export, close, properties.
use deckcraft_model::{Presentation, defaults};
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_media::MediaInfo;
use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults};
use serde_json::{Value, json};
use super::*;
@@ -33,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|rtf|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
cmd!(
query "file.info",
"Info",
@@ -43,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
info
),
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool, transparentBackground?: bool} → {png: base64, width, height}", has_doc, render),
cmd!(
query "slide.colorAt",
"Color At",
@@ -128,26 +132,45 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
open_checked(name, bytes).map(|(p, _)| p)
}
/// [`open_presentation`], plus the embedded media a `.deckcraft` file had lost or damaged.
pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
if deckcraft_format::sniff(bytes) {
return deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string()));
}
open_other(name, bytes).map(|p| (p, vec![]))
/// What a file lost on opening.
#[derive(Debug, Default, Clone)]
pub struct Damage {
/// Pictures and media a `.deckcraft` file had lost or damaged.
pub media: Vec<String>,
/// Slides a `.pptx` lists but that couldn't be read, so they are not in the presentation.
pub slides: Vec<String>,
}
fn open_other(name: &str, bytes: &[u8]) -> Result<Presentation> {
impl Damage {
pub fn is_empty(&self) -> bool {
self.media.is_empty() && self.slides.is_empty()
}
}
/// [`open_presentation`], plus what the file had lost or damaged.
pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Damage)> {
if deckcraft_format::sniff(bytes) {
let (p, media) = deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
return Ok((p, Damage { media, slides: vec![] }));
}
open_other(name, bytes).map(|(p, slides)| (p, Damage { media: vec![], slides }))
}
fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
if deckcraft_pptx::sniff(bytes) {
let mut p = deckcraft_pptx::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
deckcraft_format::repair(&mut p);
return Ok(p);
probe_clips(&mut p);
return Ok((p, lost_slides));
}
if deckcraft_pptx::is_encrypted(bytes) {
return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet.")));
}
let lower = name.to_ascii_lowercase();
if lower.ends_with(".txt") || lower.ends_with(".md") {
let rtf = deckcraft_format::rtf::sniff_rtf(bytes);
if rtf || lower.ends_with(".txt") || lower.ends_with(".md") {
let text = String::from_utf8_lossy(bytes);
let text = if rtf { std::borrow::Cow::Owned(deckcraft_format::rtf::rtf_to_outline(&text)) } else { text };
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
if lower.ends_with(".md") {
deckcraft_format::markdown_to_slides(&mut p, &text);
@@ -157,11 +180,53 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<Presentation> {
if p.slides.is_empty() {
p = defaults::new_presentation(None);
}
return Ok(p);
return Ok((p, vec![]));
}
Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read")))
}
/// Fill in each audio and video clip's length and picture size from its bytes: a `.pptx` doesn't
/// store them. Headers only, so opening never decodes a whole audio file.
fn probe_clips(p: &mut Presentation) {
fn rec(shapes: &mut [Shape], media: &[MediaItem], probed: &mut HashMap<MediaId, Option<MediaInfo>>, depth: usize) {
if depth > 64 {
return;
}
for s in shapes {
if let ShapeKind::Media(m) = &mut s.kind
&& (m.duration_ms == 0 || m.width == 0 && m.height == 0)
&& let Some(info) = probed
.entry(m.media)
.or_insert_with(|| media.iter().find(|i| i.id == m.media).and_then(|i| deckcraft_media::probe_headers(&i.data).ok()))
{
if m.duration_ms == 0 {
m.duration_ms = info.duration_ms.min(u32::MAX as u64) as u32;
}
if m.width == 0
&& m.height == 0
&& let Some(v) = &info.video
{
(m.width, m.height) = (v.width, v.height);
}
}
if let Some(children) = s.children_mut() {
rec(children, media, probed, depth + 1);
}
}
}
let mut probed = HashMap::new();
// Just read, so no slide is shared yet and `make_mut` doesn't copy.
for slide in &mut p.slides {
rec(&mut Arc::make_mut(slide).shapes, &p.media, &mut probed, 0);
}
}
/// An outline file's text: RTF is read into the tab-indented outline, anything else as UTF-8.
pub fn outline_text(bytes: &[u8]) -> String {
let text = String::from_utf8_lossy(bytes);
if deckcraft_format::rtf::sniff_rtf(bytes) { deckcraft_format::rtf::rtf_to_outline(&text) } else { text.into_owned() }
}
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
crate::fonts::install_embedded_fonts(&p);
@@ -180,8 +245,8 @@ pub struct LoadedFile {
pub doc: Presentation,
/// A native or PPTX file (saving goes back to it); an outline import has no file to save to.
pub native: bool,
/// Pictures and media a `.deckcraft` file had lost or damaged.
pub damaged: Vec<String>,
/// What the file had lost or damaged.
pub damaged: Damage,
}
/// Read and parse the presentation at `path`.
@@ -202,7 +267,10 @@ fn adopt_loaded(s: &mut Session, f: LoadedFile) -> Result<Value> {
let i = add_opened(s, &path, keep_path.then(|| path.clone()), doc);
report_damage(s, &damaged)?;
let st = s.doc()?;
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts, "damagedMedia": damaged}))
Ok(json!({
"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts,
"damagedMedia": damaged.media, "lostSlides": damaged.slides,
}))
}
#[cfg(not(target_arch = "wasm32"))]
@@ -236,23 +304,35 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let (doc, damaged) = open_checked(name, &data)?;
let i = add_opened(s, name, None, doc);
report_damage(s, &damaged)?;
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged}))
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged.media, "lostSlides": damaged.slides}))
}
/// Tell the user which pictures and media a damaged file lost, and mark the copy repaired.
fn report_damage(s: &mut Session, damaged: &[String]) -> Result<()> {
/// Tell the user which pictures, media and slides a damaged file lost, and mark the copy repaired.
fn report_damage(s: &mut Session, damaged: &Damage) -> Result<()> {
if damaged.is_empty() {
return Ok(());
}
let st = s.doc_mut()?;
st.title = format!("{} (Repaired)", st.title);
let n = damaged.len();
let text = format!(
"{n} picture{} or media file{} in this presentation couldn't be read and {} empty. It was opened as a repaired copy; save it under a new name to keep the original file.",
if n == 1 { "" } else { "s" },
if n == 1 { "" } else { "s" },
if n == 1 { "is" } else { "are" },
);
let mut text = String::new();
let n = damaged.media.len();
if n > 0 {
text = format!(
"{n} picture{} or media file{} in this presentation couldn't be read and {} empty. ",
if n == 1 { "" } else { "s" },
if n == 1 { "" } else { "s" },
if n == 1 { "is" } else { "are" },
);
}
let n = damaged.slides.len();
if n > 0 {
text.push_str(&format!(
"{n} slide{} in this file couldn't be read and {} left out. ",
if n == 1 { "" } else { "s" },
if n == 1 { "was" } else { "were" },
));
}
text.push_str("It was opened as a repaired copy; save it under a new name to keep the original file.");
s.ui_requests.push(crate::UiRequest::Message { text });
Ok(())
}
@@ -262,6 +342,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
match format {
f @ ("pptx" | "potx" | "ppsx") => deckcraft_pptx::export_as(doc, f).map_err(|e| EngineError::Other(e.to_string())),
"outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()),
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()),
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
"gif" => gif_bytes(doc, &Value::Null),
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
@@ -337,7 +418,7 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
}
/// The output extensions [`format_for_path`] maps to a writer of that format.
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md"];
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md", "rtf"];
/// The format a file named `path` is written in, or `None` when its extension names no format
/// DeckCraft writes (`.odp`, `.svg`, no extension …): [`format_for_path`] would save
@@ -370,6 +451,8 @@ pub fn format_for_path(path: &str) -> &'static str {
"gif"
} else if l.ends_with(".txt") || l.ends_with(".md") {
"outline"
} else if l.ends_with(".rtf") {
"rtf"
} else {
"deckcraft"
}
@@ -431,8 +514,14 @@ pub fn image_scale(doc: &Presentation, scale: f64) -> f64 {
/// Render slide `index` (current when absent) to PNG bytes.
pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec<u8>, u32, u32) {
render_png_with(doc, index, scale, edit, false)
}
/// [`render_png`]; `transparent` leaves the slide background out (#784).
pub fn render_png_with(doc: &Presentation, index: usize, scale: f64, edit: bool, transparent: bool) -> (Vec<u8>, u32, u32) {
let scale = image_scale(doc, scale);
let img = deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale, edit, ..Default::default() });
let opts = deckcraft_render::RenderOpts { scale, edit, transparent_background: transparent, ..Default::default() };
let img = deckcraft_render::render_slide(doc, index, &opts);
(img.to_png_at(deckcraft_render::dpi_for_scale(scale)), img.width, img.height)
}
@@ -451,7 +540,7 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
if i >= st.doc.slides.len() {
return Err(bad("file.render", format!("no slide {i}")));
}
let (png, w, h) = render_png(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false));
let (png, w, h) = render_png_with(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false), bool_or(p, "transparentBackground", false));
Ok(json!({"png": base64_encode(&png), "width": w, "height": h}))
}
@@ -483,6 +572,10 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string();
let st = s.doc()?;
let scale = finite_scale(f64_or(p, "scale", 2.0));
let transparent = bool_or(p, "transparentBackground", false);
if transparent && format != "png" {
return Err(bad("file.export", format!("a transparent background is for PNG export, not {format}")));
}
match format.as_str() {
"png" | "jpeg" | "jpg" => {
let slides: Vec<usize> = match usize_param(p, "slide") {
@@ -497,7 +590,11 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let scale = image_scale(&st.doc, scale);
let mut written = vec![];
for (k, i) in slides.iter().enumerate() {
let img = deckcraft_render::render_slide(&st.doc, *i, &deckcraft_render::RenderOpts { scale, ..Default::default() });
let img = deckcraft_render::render_slide(
&st.doc,
*i,
&deckcraft_render::RenderOpts { scale, transparent_background: transparent, ..Default::default() },
);
let bytes = picture_bytes(&img, &format, scale);
let out = if slides.len() == 1 {
path.clone()
@@ -536,7 +633,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
}))
}
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => {
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" | "rtf" => {
let bytes = save_bytes(&st.doc, &format)?;
write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
+31 -3
View File
@@ -105,7 +105,7 @@ impl Opts {
}
fn para_chars(p: &Paragraph) -> Vec<char> {
p.runs.iter().flat_map(|r| r.text.chars()).collect()
p.runs.iter().flat_map(|r| r.content().chars()).collect()
}
/// Matches that lie wholly in plain text runs (never inside a field, equation or break).
@@ -113,7 +113,7 @@ fn para_matches(p: &Paragraph, o: &Opts) -> Vec<(usize, usize)> {
let mut spans = vec![];
let mut off = 0;
for r in &p.runs {
let n = r.text.chars().count();
let n = r.char_len();
spans.push((off, off + n, matches!(r.kind, RunKind::Text)));
off += n;
}
@@ -125,7 +125,7 @@ fn splice(p: &mut Paragraph, a: usize, b: usize, with: &str) {
let mut off = 0;
let mut placed = false;
for r in &mut p.runs {
let chars: Vec<char> = r.text.chars().collect();
let chars: Vec<char> = r.content().chars().collect();
let (s, e) = (off, off + chars.len());
off = e;
if e <= a || s >= b {
@@ -222,6 +222,34 @@ mod tests {
assert_eq!(opts("cat", false, true).matches(&t), vec![(0, 3), (4, 7)]);
}
fn text_run(t: &str) -> Run {
Run { text: t.into(), props: RunProps::default(), kind: RunKind::Text }
}
fn br() -> Run {
Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break }
}
#[test]
fn a_match_never_spans_a_line_break() {
let p = Paragraph { runs: vec![text_run("a"), br(), text_run("b")], ..Default::default() };
assert!(para_matches(&p, &opts("ab", true, false)).is_empty());
}
#[test]
fn offsets_count_a_line_break_as_one_char() {
let p = Paragraph { runs: vec![text_run("ab"), br(), text_run("cd")], ..Default::default() };
assert_eq!(para_matches(&p, &opts("cd", true, false)), vec![(3, 5)]);
}
#[test]
fn splice_after_a_line_break_keeps_the_break() {
let mut p = Paragraph { runs: vec![text_run("ab"), br(), text_run("cd")], ..Default::default() };
splice(&mut p, 3, 5, "X");
assert_eq!(p.text(), "ab\u{b}X");
assert_eq!(p.runs[1].kind, RunKind::Break);
}
#[test]
fn splice_across_runs_keeps_the_first_runs_format() {
let bold = RunProps { bold: Some(true), ..Default::default() };
+84 -18
View File
@@ -4,7 +4,7 @@
use deckcraft_model::edit as ed;
use deckcraft_model::resolve;
use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir};
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape};
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape, ShapeKind};
use serde_json::{Value, json};
use super::text::{format_body, format_para, format_run};
@@ -340,6 +340,27 @@ fn shrink(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn color(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?;
// A selected chart has no runs: Font Color colours its text (title, axes, legend, labels),
// which used to answer and change nothing.
let st = s.doc()?;
if st.selection.text.is_none() {
let charts: Vec<ShapeId> =
st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect();
let only_charts = charts.len() == st.selection.shapes.len();
if !charts.is_empty() {
let tc = c.clone();
edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| {
if let ShapeKind::Chart(ch) = &mut sh.kind {
ch.text_color = Some(tc.clone());
ch.raw = None;
}
Ok(())
})?;
if only_charts {
return ok();
}
}
}
format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone())))
}
/// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it.
@@ -692,27 +713,35 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
}
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
super::text::edit_text(s, move |body, t| {
for pi in a.0..=b.0 {
let last = body.paragraphs.len().saturating_sub(1);
for pi in a.0..=b.0.min(last) {
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
let n = para.char_len();
let from = if pi == a.0 { a.1 } else { 0 };
let to = if pi == b.0 { b.1 } else { n };
let mut pos = 0;
for r in &mut para.runs {
let mut pieces = Vec::new();
for (ri, r) in para.runs.iter().enumerate() {
// Breaks and fields are read for context (a break ends a word) but not rewritten.
let len = r.char_len();
if r.kind == deckcraft_model::text::RunKind::Text {
let rs = from.max(pos).saturating_sub(pos);
let re = to.min(pos + len).saturating_sub(pos);
if re > rs {
let chars: Vec<char> = r.text.chars().collect();
let mid: String = chars.get(rs..re).map(|c| c.iter().collect()).unwrap_or_default();
let pre: String = chars.get(..rs).map(|c| c.iter().collect()).unwrap_or_default();
let post: String = chars.get(re..).map(|c| c.iter().collect()).unwrap_or_default();
r.text = format!("{pre}{}{post}", ed::change_case(&mid, &m));
}
let rs = from.max(pos).saturating_sub(pos);
let re = to.min(pos + len).saturating_sub(pos);
if re > rs {
pieces.push((ri, rs, re));
}
pos += len;
}
let mids: Vec<String> = pieces
.iter()
.map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default())
.collect();
let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::<Vec<_>>(), &m);
for (&(ri, rs, re), new) in pieces.iter().zip(changed) {
let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue };
let pre: String = r.text.chars().take(rs).collect();
let post: String = r.text.chars().skip(re).collect();
r.text = format!("{pre}{new}{post}");
}
}
if collapsed {
t.anchor = a;
@@ -734,9 +763,12 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
edit_shapes(s, &json!({}), "format.case", |sh| {
if let Some(t) = sh.text.as_mut() {
for para in &mut t.paragraphs {
for r in &mut para.runs {
// Breaks and fields are read for context (a break ends a word) but not rewritten.
let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect();
let changed = ed::change_case_parts(&texts, &mode);
for (r, new) in para.runs.iter_mut().zip(changed) {
if r.kind == deckcraft_model::text::RunKind::Text {
r.text = ed::change_case(&r.text, &mode);
r.text = new;
}
}
}
@@ -1089,7 +1121,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,
};
@@ -1160,6 +1192,31 @@ pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) {
}
}
/// The paragraphs the text selection covers whole: it starts at or before their start and
/// reaches their end. A paragraph the selection only touches the start of (it ends at offset
/// 0, as after a triple-click) is not covered.
fn covered_paragraphs(s: &Session) -> Vec<usize> {
let Ok(st) = s.doc() else { return vec![] };
let Some(t) = &st.selection.text else { return vec![] };
let Some(body) = super::text::body_of(st, t) else { return vec![] };
let (a, b) = t.ordered();
// A hostile selection can point past the body: walk only the paragraphs it has.
(a.0..=b.0.min(body.paragraphs.len().saturating_sub(1)))
.filter(|&pi| {
let Some(para) = body.paragraphs.get(pi) else { return false };
let starts = pi > a.0 || a.1 == 0;
let ends = if pi < b.0 {
true
} else if b.0 > a.0 && b.1 == 0 {
false
} else {
b.1 >= para.char_len()
};
starts && ends
})
.collect()
}
fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
let Some((painted, sticky)) = s.painter.clone() else { return Err(bad("format.painterApply", "pick formatting with the Format Painter first")) };
if !sticky {
@@ -1169,8 +1226,17 @@ fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(r) = painted.run.clone() {
format_run(s, &move |rp| *rp = r.clone())?;
}
if let Some(pp) = painted.para.clone() {
format_para(s, &move |para| para.props = pp.clone())?;
// Paragraph formatting (bullets, alignment, spacing) goes only onto paragraphs the
// selection covers whole; painting part of a bulleted line changes just its text.
let covered = covered_paragraphs(s);
if let (Some(pp), false) = (painted.para.clone(), covered.is_empty()) {
super::text::edit_text(s, |body, _| {
for &pi in &covered {
if let Some(para) = body.paragraphs.get_mut(pi) {
para.props = pp.clone();
}
}
})?;
}
return ok();
}
+225 -3
View File
@@ -4,6 +4,7 @@
//! slide. Everything is computed here, offline.
use deckcraft_geom::Xfrm;
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
use serde_json::{Value, json};
@@ -57,9 +58,44 @@ fn roles(shapes: &[Shape]) -> Roles {
r
}
/// The ideas for slide `index` (none when it has nothing to arrange).
/// Whether `s` paints a fill or an outline once inherited and style-referenced values are
/// resolved, as the renderer does (`fill: None` alone means inherited, not invisible).
fn shows(ctx: Option<&Ctx>, s: &Shape) -> bool {
let visible = |f: &Option<Fill>| f.as_ref().is_some_and(|f| !f.is_none());
match ctx {
Some(ctx) => visible(&resolve::fill(ctx, s).0) || visible(&resolve::line(ctx, s).0.fill),
None => visible(&s.fill) || visible(&s.line.as_ref().and_then(|l| l.fill.clone())),
}
}
/// Whether an idea may move this shape: a picture, a placeholder or a plain text box. Diagram
/// boxes, connectors, groups, tables, charts and media are laid out by their own geometry, which
/// the ideas do not know, so a slide holding one gets no ideas.
fn arrangeable(ctx: Option<&Ctx>, s: &Shape) -> bool {
match s.kind {
ShapeKind::Picture { .. } => true,
ShapeKind::Shape => {
let is_title = s.ph.as_ref().is_some_and(|p| p.kind.is_title());
let has_text = s.text.as_ref().is_some_and(|t| !t.text().trim().is_empty());
// Only titles and shapes with text are moved (see `roles`): an empty one that shows
// (filled or outlined) would stay where it is, over the rearranged slide.
(s.ph.is_some() || s.text_box) && (is_title || has_text || !shows(ctx, s))
}
_ => false,
}
}
/// The ideas for slide `index` (none when it has nothing to arrange, or other content in the way).
pub fn ideas_for(doc: &Presentation, index: usize) -> Vec<Idea> {
let Some(slide) = doc.slides.get(index) else { return vec![] };
let ctx = Ctx::for_slide(doc, slide);
if slide.shapes.iter().any(|s| !s.hidden && !arrangeable(ctx.as_ref(), s)) {
return vec![];
}
// The ideas move one picture; a second would stay put, over the rearranged text.
if slide.shapes.iter().filter(|s| !s.hidden && matches!(s.kind, ShapeKind::Picture { .. })).count() > 1 {
return vec![];
}
let r = roles(&slide.shapes);
if r.picture.is_some() {
PICTURE_IDEAS.to_vec()
@@ -84,6 +120,22 @@ fn cover_crop(src: f64, dst: f64) -> [f64; 4] {
}
}
/// The light fill behind the title in the full-bleed idea. Its opacity is an `alpha` transform
/// (about 220/255), as PPTX stores it, so the fill compares equal after a save and reopen.
fn band_fill() -> Fill {
Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgb(255, 255, 255)).with(deckcraft_model::ColorTransform::Alpha(86274)))
}
/// The band as released versions up to 0.10 stored it (opacity in the colour's own alpha).
fn legacy_band_fill() -> Fill {
Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220)))
}
/// Whether `f` is a Full-bleed band, in the current or the legacy form.
fn is_band(f: &Fill) -> bool {
*f == band_fill() || *f == legacy_band_fill()
}
/// Apply idea `id` to slide `index` of `doc` (`sel` resolves inherited placeholder boxes).
pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &str) -> Result<()> {
if !ideas_for(doc, index).iter().any(|i| i.0 == id) {
@@ -180,8 +232,13 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st
let pic = slide.shapes.remove(p);
slide.shapes.insert(0, pic);
}
if band && let Some(t) = slide.shapes.iter_mut().find(|s| s.ph.as_ref().is_some_and(|p| p.kind.is_title())) {
t.fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220))));
if let Some(t) = slide.shapes.iter_mut().find(|s| !s.hidden && s.ph.as_ref().is_some_and(|p| p.kind.is_title())) {
if band {
t.fill = Some(band_fill());
} else if t.fill.as_ref().is_some_and(is_band) {
// The band belongs to full-bleed only: another idea takes it away again.
t.fill = None;
}
}
Ok(())
}
@@ -228,6 +285,7 @@ mod tests {
};
let body = Shape {
id: ShapeId(501),
text_box: true,
xfrm: Some(Xfrm::new(50.0, 140.0, 860.0, 350.0)),
text: Some(deckcraft_model::text::TextBody::from_text("one\ntwo")),
..Default::default()
@@ -314,6 +372,170 @@ mod tests {
assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err());
}
#[test]
fn switching_from_full_bleed_removes_the_title_band() {
let mut s = deck(true);
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
assert!(shape(&s, 500).fill.is_none(), "the band belongs to full-bleed only");
}
#[test]
fn the_band_goes_on_the_visible_title_not_a_hidden_one_before_it() {
let mut s = deck(true);
let st = s.doc_mut().unwrap();
let slide = Arc::make_mut(&mut Arc::make_mut(&mut st.doc).slides[0]);
let hidden = Shape {
id: ShapeId(503),
hidden: true,
ph: Some(Placeholder { kind: PhType::Title, ..Default::default() }),
xfrm: Some(Xfrm::new(50.0, 30.0, 860.0, 90.0)),
text: Some(deckcraft_model::text::TextBody::from_text("Old title")),
..Default::default()
};
slide.shapes.insert(0, hidden);
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
assert_eq!(shape(&s, 500).fill, Some(band_fill()), "visible title gets the band");
assert!(shape(&s, 503).fill.is_none(), "hidden title is untouched");
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
assert!(shape(&s, 500).fill.is_none(), "switching clears the visible title's band");
assert!(shape(&s, 503).fill.is_none());
}
#[test]
fn switching_ideas_removes_the_band_after_a_pptx_round_trip() {
let mut s = deck(true);
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
let back = deckcraft_pptx::import(&bytes).unwrap();
s.doc_mut().unwrap().doc = Arc::new(back);
let title = s.doc().unwrap().doc.slides[0].shapes.iter().position(|x| x.ph.as_ref().is_some_and(|p| p.kind.is_title())).unwrap();
let id = s.doc().unwrap().doc.slides[0].shapes[title].id.0;
assert_eq!(shape(&s, id).fill, Some(band_fill()), "band survives the save");
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
assert!(shape(&s, id).fill.is_none(), "the band is removed after reopening");
}
#[test]
fn switching_ideas_removes_a_band_saved_by_earlier_versions() {
let mut s = deck(true);
let legacy = Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220)));
let st = s.doc_mut().unwrap();
let slide = Arc::make_mut(&mut Arc::make_mut(&mut st.doc).slides[0]);
slide.shapes.iter_mut().find(|x| x.id == ShapeId(500)).unwrap().fill = Some(legacy);
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
assert!(shape(&s, 500).fill.is_none());
}
#[test]
fn a_title_fill_set_by_the_user_survives_other_ideas() {
// Set through the command, and set to exactly the band's own fill.
let mut s = deck(true);
s.execute("shape.fill", &json!({"id": 500, "color": "#ffffff", "transparency": 0.14})).unwrap();
let user = shape(&s, 500).fill;
assert!(user.is_some());
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
assert_eq!(shape(&s, 500).fill, user);
}
#[test]
fn full_bleed_twice_keeps_the_band_and_undo_after_switching_brings_it_back() {
let mut s = deck(true);
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
s.execute("design.applyIdea", &json!({"id": "framed"})).unwrap();
assert!(shape(&s, 500).fill.is_none());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
}
#[test]
fn slides_with_diagrams_or_other_graphics_get_no_ideas() {
// A filled autoshape with text (a diagram box) is not text to restack.
let mut s = deck(false);
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
slide.shapes.push(Shape {
id: ShapeId(510),
xfrm: Some(Xfrm::new(300.0, 200.0, 200.0, 80.0)),
fill: Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255)))),
text: Some(deckcraft_model::text::TextBody::from_text("Solar wind")),
..Default::default()
});
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
assert!(s.execute("design.applyIdea", &json!({"id": "titleColumn"})).is_err());
// A connector too, and a hidden one does not count.
let mut s = deck(false);
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
slide.shapes.push(Shape {
id: ShapeId(511),
kind: ShapeKind::Connector { start: None, end: None },
xfrm: Some(Xfrm::new(300.0, 200.0, 100.0, 0.0)),
..Default::default()
});
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[2].hidden = true;
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3);
}
#[test]
fn two_visible_pictures_get_no_ideas_but_a_hidden_second_one_does_not_count() {
let mut s = deck(true);
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
slide.shapes.push(Shape {
id: ShapeId(503),
kind: ShapeKind::Picture { fill: PictureFill::default() },
xfrm: Some(Xfrm::new(600.0, 300.0, 200.0, 100.0)),
..Default::default()
});
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
assert!(s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).is_err());
Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[3].hidden = true;
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 4);
}
#[test]
fn an_empty_text_box_blocks_ideas_only_when_it_shows() {
let mut s = deck(false);
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
slide.shapes.push(Shape { id: ShapeId(512), text_box: true, xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)), ..Default::default() });
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "invisible and empty: nothing in the way");
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
slide.shapes[2].fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255))));
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "a filled empty box would stay on top");
}
#[test]
fn an_empty_box_with_an_explicit_no_fill_does_not_block_ideas() {
let mut s = deck(false);
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
slide.shapes.push(Shape {
id: ShapeId(512),
text_box: true,
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)),
fill: Some(Fill::None),
..Default::default()
});
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "an explicit no fill shows nothing");
}
#[test]
fn an_empty_box_filled_through_its_style_blocks_ideas() {
let mut s = deck(false);
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
let mut style = deckcraft_model::style::ShapeStyle::accent(deckcraft_model::SchemeSlot::Accent1);
style.line_ref.0 = 0;
slide.shapes.push(Shape {
id: ShapeId(512),
text_box: true,
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)),
style: Some(style),
..Default::default()
});
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "the theme fill shows, so the box would stay on top");
}
#[test]
fn cover_crop_matches_the_box_aspect() {
let c = cover_crop(2.0, 1.0);
+53 -29
View File
@@ -118,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Chart Elements",
["Chart Design", "Chart Layouts"],
None,
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, id?}",
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, textColor?: #RRGGBB|accent1..6|tx1…|null (title, axes, legend and labels; null for PowerPoint's default: the theme's text colour at 65 % plus 35 %, 595959 on a light theme), id?}",
has_selection,
chart_options
),
@@ -577,7 +577,7 @@ fn commit_album(s: &mut Session, items: Vec<AlbumItem>, opts: AlbumOpts, replace
}
fn album_norm(s: &str) -> String {
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
}
fn album_layout(p: &Value) -> Result<AlbumLayout> {
@@ -770,7 +770,7 @@ fn image_px(bytes: &[u8]) -> (f64, f64) {
}
fn file_stem(name: &str) -> String {
let base = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name);
let base = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name);
let stem = match base.rsplit_once('.') {
Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s,
_ => base,
@@ -1761,6 +1761,39 @@ fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option<Option<u32>>, bin_size: Option<Option<f64>>) {
if let Some(n) = bin_count {
c.bin_count = n;
if n.is_some() {
c.bin_size = None;
}
}
if let Some(w) = bin_size {
c.bin_size = w;
if w.is_some() {
c.bin_count = None;
}
}
}
fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) {
if let Some(t) = subtotals {
let n = c.series.first().map_or(0, |s| s.values.len());
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
t.sort_unstable();
t.dedup();
c.subtotals = t;
}
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
c.up_down_bars = b;
c.raw = None;
}
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
c.raw = None;
}
}
fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
// Histogram bins: a count or a width (setting one clears the other; null for automatic).
let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32));
@@ -1771,34 +1804,21 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
// Waterfall total points.
let subtotals: Option<Vec<usize>> =
p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect());
// Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's
// default (tx1 at lumMod 65 % / lumOff 35 %).
let text_color = match p.get("textColor") {
None => None,
Some(Value::Null) => Some(None),
Some(v) => {
Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?))
}
};
with_chart(s, p, "chart.options", |c| {
if let Some(n) = bin_count {
c.bin_count = n;
if n.is_some() {
c.bin_size = None;
}
}
if let Some(w) = bin_size {
c.bin_size = w;
if w.is_some() {
c.bin_count = None;
}
}
if let Some(t) = &subtotals {
let n = c.series.first().map_or(0, |s| s.values.len());
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
t.sort_unstable();
t.dedup();
c.subtotals = t;
}
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
c.up_down_bars = b;
c.raw = None;
}
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
c.raw = None;
if let Some(t) = &text_color {
c.text_color = t.clone();
}
apply_bin_options(c, bin_count, bin_size);
apply_subtotals_and_bars(c, p, subtotals.as_deref());
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
c.raw = None;
}
@@ -1810,6 +1830,10 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(v) = bool_param(p, "dataLabels") {
c.data_labels = v;
// A series' own setting would otherwise keep overriding the chart-wide switch.
for series in &mut c.series {
series.data_labels = None;
}
}
if let Some(v) = bool_param(p, "gridlines") {
c.gridlines = v;
+15
View File
@@ -228,6 +228,21 @@ mod tests {
assert!(s.execute("media.posterFrame", &json!({})).is_err(), "audio has no poster frame");
}
#[test]
fn a_video_reopened_from_pptx_keeps_its_length_and_size() {
let mut s = Session::with_new();
let clip = include_bytes!("../../../media/tests/fixtures/clip-h264.mp4");
s.execute("insert.video", &json!({"name": "clip.mp4", "data": base64_encode(clip)})).expect("insert");
let before = s.execute("media.info", &json!({})).expect("info");
assert!(before["durationMs"].as_u64().is_some_and(|ms| ms > 0));
let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).expect("save");
s.execute("file.openBytes", &json!({"name": "deck.pptx", "data": saved["data"]})).expect("open");
let after = s.execute("media.info", &json!({})).expect("info");
for key in ["durationMs", "width", "height"] {
assert_eq!(after[key], before[key], "{key}");
}
}
#[test]
fn media_commands_need_media() {
let mut s = Session::with_new();
+8
View File
@@ -318,6 +318,14 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
pub(crate) fn usize_param(p: &Value, key: &str) -> Option<usize> {
p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok())
}
/// An index param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
/// present but not a valid index (negative, non-integer), so a bad index never acts on the selection.
pub(crate) fn opt_usize_param(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` is not a valid index: {v}"))),
}
}
/// A shape id param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
/// present but not a valid id (negative, non-integer, beyond `u32`), so a bad id never edits the selection.
pub(crate) fn id_param(p: &Value, key: &str, cmd: &str) -> Result<Option<ShapeId>> {
+185 -17
View File
@@ -16,7 +16,7 @@ use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
noundo "shape.editPoints",
"shape.editPoints",
"Edit Points",
["Shape Format", "Insert Shapes", "Edit Shape"],
None,
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
edit_points
),
cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]]} vertices of a freeform", has_selection, points),
cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]], segments: [[straight|curved|null]]} vertices of a freeform and the segment ending at each", has_selection, points),
cmd!("shape.movePoint", "Move Point", [], None, "{path?: 0, index, x, y: slide pt, id?}", has_selection, move_point),
cmd!(
"shape.addPoint",
@@ -54,6 +54,33 @@ pub fn specs() -> Vec<CommandSpec> {
move_handle
),
cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point),
cmd!(
"shape.segmentType",
"Segment Type",
[],
None,
"{path?: 0, segment: index of the vertex ending it, type: straight|curved, id?} — straight drops the segment's handles; curved gives it handles at its thirds",
has_selection,
segment_type
),
cmd!(
"shape.openPath",
"Open Path",
[],
None,
"{path?: 0, index, id?} — break a closed outline at a point, which becomes its two ends",
has_selection,
open_path
),
cmd!(
"shape.closePath",
"Close Path",
[],
None,
"{path?: 0, index: any point of the open outline, id?} — join its ends with a straight segment (one point when they meet)",
has_selection,
close_path
),
]
}
@@ -281,6 +308,76 @@ fn remove_vertex(segs: &mut Vec<Seg>, si: usize) -> bool {
true
}
/// The sub-path holding segment `si`: from its move up to the next move (or the end).
fn subpath(segs: &[Seg], si: usize) -> (usize, usize) {
let from = segs.get(..=si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_)))).unwrap_or(0);
let to = (si + 1..segs.len()).find(|j| matches!(segs.get(*j), Some(Seg::M(_)))).unwrap_or(segs.len());
(from, to)
}
/// The segment that ends at the vertex ending segment `si`: the closing edge for a closed
/// sub-path's start, none for an open one's.
fn ending_segment(segs: &[Seg], si: usize) -> Option<usize> {
if matches!(segs.get(si), Some(Seg::M(_))) {
(si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j))
} else {
Some(si)
}
}
/// Whether segment `j` is a straight line or a curve.
fn segment_kind(segs: &[Seg], j: usize) -> Option<&'static str> {
match segs.get(j)? {
Seg::L(_) => Some("straight"),
Seg::C(..) | Seg::Q(..) => Some("curved"),
Seg::M(_) | Seg::Z => None,
}
}
/// Makes segment `j` a straight line (dropping its handles) or a curve with handles at its
/// thirds, so it keeps its shape until a handle is dragged.
fn set_segment(segs: &mut [Seg], j: usize, curved: bool) {
if curved {
to_cubic(segs, j);
} else if let Some(s) = segs.get_mut(j)
&& let Seg::C(_, _, p) | Seg::Q(_, p) = *s
{
*s = Seg::L(p);
}
}
/// Opens the closed sub-path through the vertex ending segment `si` there: it now starts and ends
/// on that vertex, as two points. Returns false for an open sub-path.
fn open_at(segs: &mut Vec<Seg>, si: usize) -> bool {
let (from, to) = subpath(segs, si);
let Some(z) = to.checked_sub(1).filter(|z| *z > si && matches!(segs.get(*z), Some(Seg::Z))) else { return false };
let Some(v) = segs.get(si).and_then(Seg::end) else { return false };
let (Some(after), Some(before)) = (segs.get(si + 1..z), segs.get(from + 1..=si)) else { return false };
let mut out = vec![Seg::M(v)];
out.extend_from_slice(after);
out.extend_from_slice(before);
segs.splice(from..to, out);
true
}
/// Closes the open sub-path through the vertex ending segment `si`: a straight segment joins its
/// ends, or they become one point when they meet. Returns false for a closed sub-path.
fn close_at(segs: &mut Vec<Seg>, si: usize) -> bool {
let (from, to) = subpath(segs, si);
let last = to.saturating_sub(1);
if last <= from || matches!(segs.get(last), Some(Seg::Z)) {
return false;
}
let (Some(Seg::M(start)), Some(end)) = (segs.get(from).copied(), segs.get(last).and_then(Seg::end)) else { return false };
let mut add = Vec::new();
if (start - end).hypot() >= 1e-6 {
add.push(Seg::L(start));
}
add.push(Seg::Z);
segs.splice(to..to, add);
true
}
/// Custom paths of a preset shape, in shape-local points.
fn to_custom(sh: &Shape, w: f64, h: f64) -> Vec<CustomPath> {
let geo = deckcraft_render::shape_geometry(sh, w, h);
@@ -355,8 +452,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")),
@@ -402,6 +499,7 @@ pub(crate) fn prepare(s: &mut Session, id: ShapeId) -> Result<()> {
fn edit_points(s: &mut Session, p: &Value) -> Result<Value> {
let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.edit_points.is_none());
s.tool.edit_vertex = None;
s.tool.edit_segment = None;
if !on {
s.tool.edit_points = None;
return Ok(json!({"editing": Value::Null}));
@@ -429,24 +527,23 @@ 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"))?;
let paths: Vec<Vec<[f64; 2]>> =
slide_vertices(&st.doc, &st.selection, sh).into_iter().map(|v| v.into_iter().map(|q| [q.x, q.y]).collect()).collect();
let types: Vec<Vec<&str>> = match &sh.geom {
Geom::Custom { paths } => paths
.iter()
.map(|cp| {
let segs = parse(&cp.d);
vertices(&segs).iter().map(|si| point_kind(&segs, *si)).collect()
})
.collect(),
Geom::Preset { .. } => vec![],
};
Ok(json!({"id": id.0, "paths": paths, "types": types}))
let (mut types, mut kinds): (Vec<Vec<&str>>, Vec<Vec<Option<&str>>>) = (vec![], vec![]);
if let Geom::Custom { paths } = &sh.geom {
for cp in paths {
let segs = parse(&cp.d);
let vs = vertices(&segs);
types.push(vs.iter().map(|si| point_kind(&segs, *si)).collect());
kinds.push(vs.iter().map(|si| ending_segment(&segs, *si).and_then(|j| segment_kind(&segs, j))).collect());
}
}
Ok(json!({"id": id.0, "paths": paths, "types": types, "segments": kinds}))
}
/// Runs `f` on the segments of path `path` of `id` (in shape-local points), then refits the box.
@@ -784,3 +881,74 @@ pub fn kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> {
pub(crate) fn move_handle_raw(s: &mut Session, p: &Value) -> Result<Value> {
move_handle(s, p)
}
fn segment_type(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.segmentType")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let seg = index_param(p, "segment", "shape.segmentType")?;
let curved = match str_param(p, "type") {
Some("straight") => false,
Some("curved") => true,
_ => return Err(bad("shape.segmentType", "`type` is straight or curved")),
};
// Already that type: nothing to change, so no undo step.
let want = if curved { "curved" } else { "straight" };
if s.doc()?.shape(id).is_some_and(|sh| segment_kind_of(sh, path, seg) == Some(want)) {
return ok();
}
edit_path(s, id, path, "shape.segmentType", move |segs, vs, _| {
let si = *vs.get(seg).ok_or_else(|| bad("shape.segmentType", "no such segment"))?;
let j = ending_segment(segs, si).ok_or_else(|| bad("shape.segmentType", "the first point of an open outline ends no segment"))?;
set_segment(segs, j, curved);
Ok(true)
})
}
fn open_path(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.openPath")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let index = index_param(p, "index", "shape.openPath")?;
let r = edit_path(s, id, path, "shape.openPath", move |segs, vs, _| {
let si = *vs.get(index).ok_or_else(|| bad("shape.openPath", "no such point"))?;
if open_at(segs, si) { Ok(true) } else { Err(bad("shape.openPath", "the outline is open already")) }
})?;
forget_kinds(s, path);
Ok(r)
}
fn close_path(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.closePath")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let index = index_param(p, "index", "shape.closePath")?;
let r = edit_path(s, id, path, "shape.closePath", move |segs, vs, _| {
let si = *vs.get(index).ok_or_else(|| bad("shape.closePath", "no such point"))?;
if close_at(segs, si) { Ok(true) } else { Err(bad("shape.closePath", "the outline is closed already")) }
})?;
forget_kinds(s, path);
Ok(r)
}
/// Opening or closing renumbers the points of `path`: a point type remembered for one of them
/// would follow the wrong point.
fn forget_kinds(s: &mut Session, path: usize) {
if s.tool.vertex_kind.as_ref().is_some_and(|k| k.0 == path) {
s.tool.vertex_kind = None;
}
}
/// Whether the segment ending at vertex `index` of path `path` of `sh` is straight or curved.
pub fn segment_kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> {
let Geom::Custom { paths } = &sh.geom else { return None };
let segs = parse(&paths.get(path)?.d);
let si = *vertices(&segs).get(index)?;
segment_kind(&segs, ending_segment(&segs, si)?)
}
/// Whether the outline through vertex `index` of path `path` of `sh` is closed.
pub fn closed_at(sh: &Shape, path: usize, index: usize) -> Option<bool> {
let Geom::Custom { paths } = &sh.geom else { return None };
let segs = parse(&paths.get(path)?.d);
let si = *vertices(&segs).get(index)?;
let (_, to) = subpath(&segs, si);
Some(matches!(segs.get(to.checked_sub(1)?), Some(Seg::Z)))
}
+107 -3
View File
@@ -1,8 +1,9 @@
//! Picture Format ▸ Color: Recolor presets and Set Transparent Color.
use deckcraft_color::{Rgba, SchemeSlot};
use deckcraft_model::style::PictureAdjust;
use deckcraft_model::{ColorRef, ShapeKind};
use deckcraft_geom::{Affine, Point, Rect, Xfrm};
use deckcraft_model::style::{PictureAdjust, PictureMode};
use deckcraft_model::{ColorRef, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
@@ -27,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Transparent Color",
["Picture Format", "Adjust", "Color"],
None,
"{color: #RRGGBB | none: true, ids?} (pixels of that colour become transparent)",
"{color: #RRGGBB | at: [x, y] | none: true, ids?} (pixels of that colour become transparent; `at` takes the colour of the picture's own pixel under that slide point, as a click does in PowerPoint)",
has_selection,
transparent_color
),
@@ -121,6 +122,15 @@ fn recolor(s: &mut Session, p: &Value) -> Result<Value> {
}
fn transparent_color(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(at) = p.get("at") {
let (id, c) = picture_pixel_at(s, p, at)?;
return edit_shapes(s, &json!({"ids": [id]}), "picture.transparentColor", |sh| {
if let ShapeKind::Picture { fill } = &mut sh.kind {
fill.adjust.clear_color = Some(c);
}
Ok(())
});
}
let c = if bool_or(p, "none", false) {
None
} else {
@@ -134,3 +144,97 @@ fn transparent_color(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
})
}
/// The topmost target picture under slide point `at` and the colour of its own pixel there: the
/// picture as stored, before corrections and recolouring, so the colour matches the pixels that
/// `clear_color` is compared with.
fn picture_pixel_at(s: &Session, p: &Value, at: &Value) -> Result<(ShapeId, Rgba)> {
const CMD: &str = "picture.transparentColor";
let xy = |i: usize| at.get(i).and_then(Value::as_f64).filter(|v| v.is_finite());
let (Some(x), Some(y)) = (xy(0), xy(1)) else {
return Err(bad(CMD, "`at` is [x, y] in slide units"));
};
let mut ids = targets(s, p)?;
let st = s.doc()?;
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?;
// Same order as the hit-test: later in the shape list is in front, and a group's children sit
// where the group is. The selection order says nothing about what is on top.
ids.sort_by_cached_key(|id| std::cmp::Reverse(stack_path(shapes, *id)));
for id in ids {
let Some((sh, xf, to_slide)) = placed(&st.doc, &st.selection, shapes, id, Affine::IDENTITY, 0) else { continue };
let ShapeKind::Picture { fill } = &sh.kind else { continue };
// The camera turns the picture where it is drawn, so the click is mapped back through it too.
let turn = text::ctx_for(&st.doc, &st.selection).and_then(|c| deckcraft_render::camera_turn(&c, sh, xf.w, xf.h)).unwrap_or(Affine::IDENTITY);
let local = (to_slide * turn).inverse() * Point::new(x, y);
if !(local.is_finite() && (0.0..=xf.w).contains(&local.x) && (0.0..=xf.h).contains(&local.y)) {
continue;
}
let PictureMode::Stretch { fill_rect: [l, t, r, b] } = fill.mode else {
return Err(bad(CMD, "a tiled picture has no single pixel under a point; give `color`"));
};
let inner = Rect::new(l * xf.w, t * xf.h, xf.w - r * xf.w, xf.h - b * xf.h);
let dest = deckcraft_render::crop_dest(inner, fill.crop);
let img =
st.doc.media(fill.media).and_then(|m| deckcraft_render::decode_image(&m.data)).ok_or_else(|| bad(CMD, "the picture can't be read"))?;
let (u, v) = ((local.x - dest.x0) / dest.width(), (local.y - dest.y0) / dest.height());
if !((0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v)) {
continue;
}
let (w, h) = (img.width(), img.height());
let px = ((u * f64::from(w)) as u32).min(w.saturating_sub(1));
let py = ((v * f64::from(h)) as u32).min(h.saturating_sub(1));
let Some(c) = img.get_pixel_checked(px, py) else { continue };
// A transparent pixel keeps a colour nobody sees (often black): clearing it would clear
// that colour where it is drawn.
if c[3] == 0 {
return Err(bad(CMD, "that spot of the picture is already transparent"));
}
return Ok((id, Rgba::rgb(c[0], c[1], c[2])));
}
Err(bad(CMD, "the point is not on a selected picture"))
}
/// Where `id` sits in the paint order: its index in each list from the slide down to it. A larger
/// path is drawn later, so in front.
fn stack_path(shapes: &[Shape], id: ShapeId) -> Option<Vec<usize>> {
for (i, sh) in shapes.iter().enumerate() {
if sh.id == id {
return Some(vec![i]);
}
if let ShapeKind::Group { children, .. } = &sh.kind
&& let Some(mut rest) = stack_path(children, id)
{
rest.insert(0, i);
return Some(rest);
}
}
None
}
/// Shape `id` among `shapes` (searched into groups), its box, and the map from that box to slide
/// space.
fn placed<'a>(
doc: &deckcraft_model::Presentation,
sel: &crate::Selection,
shapes: &'a [Shape],
id: ShapeId,
to_slide: Affine,
depth: usize,
) -> Option<(&'a Shape, Xfrm, Affine)> {
if depth > 64 {
return None;
}
for sh in shapes {
let x = xfrm_of(doc, sel, sh);
if sh.id == id {
return Some((sh, x, to_slide * x.affine()));
}
if let ShapeKind::Group { children, child } = &sh.kind {
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
if let Some(found) = placed(doc, sel, children, id, to_slide * deckcraft_geom::group_child_affine(&x, ch), depth + 1) {
return Some(found);
}
}
}
None
}
+230 -33
View File
@@ -3,27 +3,48 @@
//! The slides take the destination's look by default (each one moves to the destination layout
//! with the same name, else the same kind); with `keepFormatting` the source master and its
//! layouts come along. Pictures, media and sounds are copied under fresh ids, and shape ids are
//! renumbered with animations, triggers and connector ends following them.
//! renumbered with animations, triggers and connector ends following them. Links to another
//! reused slide follow its copy.
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session, media_refs};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"slide.reuse",
"Reuse Slides…",
["Home", "Slides", "New Slide"],
None,
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
has_doc,
reuse
)]
vec![
cmd!(
query "slide.reuseList",
"List Slides to Reuse",
[],
None,
"{path | data: base64, name?} → {name, slides: [{index, title, hidden}]}: the slides of another presentation, for choosing which ones `slide.reuse` brings in",
has_doc,
reuse_list
),
cmd!(
"slide.reuse",
"Reuse Slides…",
["Home", "Slides", "New Slide"],
None,
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
has_doc,
reuse
),
]
}
fn reuse_list(_s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes) = super::insert::media_bytes(p, "slide.reuseList")?;
let src = super::file::open_presentation(&name, &bytes)?;
let slides: Vec<Value> =
src.slides.iter().enumerate().map(|(index, sl)| json!({"index": index, "title": sl.title(), "hidden": sl.hidden})).collect();
Ok(json!({"name": name, "slides": slides}))
}
/// Point every media id under `x` at its copy in the destination (`map`: source → destination).
@@ -60,10 +81,61 @@ fn remap_connectors(shapes: &mut [deckcraft_model::Shape], map: &HashMap<ShapeId
}
}
/// Point a link to a reused slide at its copy (`map`: source → destination).
fn relink(h: &mut Option<Hyperlink>, map: &HashMap<SlideId, SlideId>) {
let Some(h) = h else { return };
if let Action::Slide { slide } = &mut h.action {
*slide = map.get(slide).copied().unwrap_or(*slide);
}
if let Some(z) = &mut h.zoom_return {
*z = map.get(z).copied().unwrap_or(*z);
}
}
fn relink_run(r: &mut RunProps, map: &HashMap<SlideId, SlideId>) {
relink(&mut r.link, map);
relink(&mut r.hover, map);
}
fn relink_text(t: &mut TextBody, map: &HashMap<SlideId, SlideId>) {
for lv in t.list_style.levels.iter_mut().flatten() {
relink_run(&mut lv.run, map);
}
for p in &mut t.paragraphs {
relink_run(&mut p.def_props, map);
relink_run(&mut p.end_props, map);
for r in &mut p.runs {
relink_run(&mut r.props, map);
}
}
}
/// Point the slide links under `shapes` (click and hover actions, text links) at the copies.
fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap<SlideId, SlideId>, depth: usize) {
if depth > 64 {
return;
}
for sh in shapes {
relink(&mut sh.click, map);
relink(&mut sh.hover, map);
if let Some(t) = &mut sh.text {
relink_text(t, map);
}
if let ShapeKind::Table(t) = &mut sh.kind {
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
relink_text(&mut c.text, map);
}
}
if let Some(ch) = sh.children_mut() {
relink_shapes(ch, map, depth + 1);
}
}
}
/// Fresh shape ids for `shapes`, and the old → new map.
fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
fn renumber(cmd: &str, doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
let old = shape_ids(shapes);
super::edit::reid("slide.reuse", doc, shapes)?;
super::edit::reid(cmd, doc, shapes)?;
let map: HashMap<ShapeId, ShapeId> = old.into_iter().zip(shape_ids(shapes)).collect();
remap_connectors(shapes, &map, 0);
Ok(map)
@@ -118,30 +190,20 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
masters_done.push(m.id);
let mut used = vec![];
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
copy_media(doc, &src, &used, &mut media_map)?;
let mut m = m.clone();
media_refs::visit_mut::master(&mut m, &mut remap(&media_map));
m.id = MasterId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
renumber(doc, &mut m.shapes)?;
for l in &mut m.layouts {
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
layout_map.insert(l.id, new);
l.id = new;
renumber(doc, &mut l.shapes)?;
}
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
doc.masters.push(Arc::new(m));
}
}
let mut new_ids = vec![];
for (k, &i) in picked.iter().enumerate() {
let mut copies: Vec<Slide> = vec![];
// Source slide → its copy, so links between reused slides follow (forward ones too).
let mut slide_map: HashMap<SlideId, SlideId> = HashMap::new();
for &i in &picked {
let Some(from) = src.slides.get(i) else { continue };
copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map)?;
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
let mut sl: Slide = from.as_ref().clone();
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
let map = renumber(doc, &mut sl.shapes)?;
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
for a in &mut sl.animations {
a.shape = map.get(&a.shape).copied().unwrap_or(a.shape);
a.trigger = a.trigger.map(|t| map.get(&t).copied().unwrap_or(t));
@@ -154,6 +216,13 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
matching_layout(doc, &lname, kind)
}
};
slide_map.insert(from.id, sl.id);
copies.push(sl);
}
let mut new_ids = vec![];
for (k, mut sl) in copies.into_iter().enumerate() {
relink_shapes(&mut sl.shapes, &slide_map, 0);
relink_text(&mut sl.notes, &slide_map);
new_ids.push(sl.id);
doc.slides.insert((at + k).min(doc.slides.len()), Arc::new(sl));
}
@@ -165,9 +234,35 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// A copy of `src`'s master `m` ready to join `doc`: its media copied in, fresh master, layout and
/// shape ids. `layouts` gains each source layout id → its copy's.
pub(crate) fn import_master(
cmd: &str,
doc: &mut Presentation,
src: &Presentation,
m: &Master,
media_map: &mut HashMap<MediaId, MediaId>,
layouts: &mut HashMap<LayoutId, LayoutId>,
) -> Result<Master> {
let mut used = vec![];
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
copy_media(cmd, doc, src, &used, media_map)?;
let mut m = m.clone();
media_refs::visit_mut::master(&mut m, &mut remap(media_map));
m.id = MasterId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
renumber(cmd, doc, &mut m.shapes)?;
for l in &mut m.layouts {
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
layouts.insert(l.id, new);
l.id = new;
renumber(cmd, doc, &mut l.shapes)?;
}
Ok(m)
}
/// Copy the source media `used` into `doc` under fresh ids, once each (identical bytes already in
/// `doc` are shared; a linked item keeps its link).
fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
for &id in used {
if map.contains_key(&id) {
continue;
@@ -175,12 +270,12 @@ fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map:
let Some(m) = src.media(id) else { continue };
let new = if m.link.is_some() {
let mut item = m.clone();
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
let new = item.id;
doc.media.push(item);
new
} else {
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad(cmd, NO_ID_SPACE))?
};
map.insert(id, new);
}
@@ -263,4 +358,106 @@ mod tests {
want.sort();
assert_eq!(got, want, "every reference resolves to its own copied bytes");
}
fn link_to(slide: SlideId) -> Hyperlink {
Hyperlink {
action: Action::Slide { slide },
tooltip: String::new(),
highlight_click: false,
sound: None,
stop_sound: false,
zoom_return: None,
}
}
fn linked_text(slide: SlideId) -> TextBody {
let mut text = TextBody::from_text("go");
if let Some(r) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
r.props.link = Some(link_to(slide));
}
text
}
/// Links to a slide that is reused too follow the copy; links to a slide left behind stay.
#[test]
fn reused_slides_link_to_each_others_copies() {
let mut src = defaults::new_presentation(None);
let template = src.slides.first().map(|s| s.as_ref().clone()).expect("slide");
for _ in 0..2 {
let mut sl = template.clone();
sl.id = SlideId(src.alloc_id().expect("id"));
src.slides.push(Arc::new(sl));
}
let ids: Vec<SlideId> = src.slides.iter().map(|s| s.id).collect();
let id = |i: usize| ids.get(i).copied().unwrap_or_default();
if let Some(sl) = src.slides.first_mut().map(Arc::make_mut) {
sl.shapes = vec![
Shape { id: ShapeId(9001), click: Some(link_to(id(1))), ..Default::default() },
Shape { id: ShapeId(9002), text: Some(linked_text(id(1))), ..Default::default() },
Shape { id: ShapeId(9003), click: Some(link_to(id(2))), ..Default::default() },
];
}
if let Some(sl) = src.slides.get_mut(1).map(Arc::make_mut) {
sl.shapes = vec![Shape { id: ShapeId(9004), click: Some(link_to(id(0))), ..Default::default() }];
}
let bytes = deckcraft_format::save(&src).expect("save");
let reopened = crate::cmd::file::open_presentation("src.deckcraft", &bytes).expect("open");
let left_behind = reopened.slides.get(2).map(|s| s.id).expect("third slide");
let mut s = Session::with_new();
for _ in 0..3 {
s.execute("slide.new", &json!({})).expect("new");
}
let out = s
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&bytes), "name": "src.deckcraft", "slides": [0, 1]}))
.expect("reuse");
let new: Vec<SlideId> = serde_json::from_value(out.get("ids").cloned().unwrap_or_default()).expect("ids");
let doc = &s.doc().expect("doc").doc;
let copy = |i: usize| new.get(i).and_then(|id| doc.slide(*id)).expect("copied slide");
let click = |sh: Option<&Shape>| sh.and_then(|sh| sh.click.as_ref()).map(|h| h.action.clone());
let first = copy(0);
let to = |i: usize| Some(Action::Slide { slide: new.get(i).copied().unwrap_or_default() });
assert_eq!(click(first.shapes.first()), to(1), "shape click follows the copy");
let run_link = first
.shapes
.get(1)
.and_then(|sh| sh.text.as_ref())
.and_then(|t| t.paragraphs.first())
.and_then(|p| p.runs.first())
.and_then(|r| r.props.link.as_ref())
.map(|h| h.action.clone());
assert_eq!(run_link, to(1), "text link follows the copy");
assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link");
assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too");
}
/// The list that lets a dialog offer the slides of the other file; picking from it brings in
/// exactly those.
#[test]
fn reuse_list_names_the_slides_and_the_picked_ones_come_in() {
let mut src = Session::with_new();
for t in ["Alpha", "Beta", "Gamma"] {
src.execute("slide.new", &json!({"layout": "titleAndContent", "title": t})).expect("new");
}
let last_slide = src.doc().expect("doc").doc.slides.len() - 1;
src.execute("slide.hide", &json!({"index": last_slide})).expect("hide");
let bytes = crate::cmd::file::save_bytes(&src.doc().expect("doc").doc, "deckcraft").expect("save");
let data = super::super::base64_encode(&bytes);
let mut s = Session::with_new();
let listed = s.execute("slide.reuseList", &json!({"data": data, "name": "src.deckcraft"})).expect("list");
let slides = listed["slides"].as_array().expect("slides");
let title = |i: usize| slides.iter().find(|x| x["index"] == i).and_then(|x| x["title"].as_str()).unwrap_or_default().to_string();
assert!(slides.len() >= 3);
let (a, b, g) = (title(slides.len() - 3), title(slides.len() - 2), title(slides.len() - 1));
assert_eq!((a.as_str(), b.as_str(), g.as_str()), ("Alpha", "Beta", "Gamma"));
assert_eq!(slides.last().map(|x| x["hidden"].clone()), Some(json!(true)));
assert_eq!(s.doc().expect("doc").doc.slides.len(), 1, "listing changes nothing");
let last = slides.len() - 1;
s.execute("slide.reuse", &json!({"data": data, "name": "src.deckcraft", "slides": [last - 2, last]})).expect("reuse");
let titles: Vec<String> = s.doc().expect("doc").doc.slides.iter().map(|x| x.title()).collect();
assert!(titles.iter().any(|t| t == "Alpha") && titles.iter().any(|t| t == "Gamma"));
assert!(!titles.iter().any(|t| t == "Beta"), "a slide that was not picked stays out");
}
}
+54 -5
View File
@@ -59,6 +59,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
cmd!(
noundo "review.reject",
"Reject Revision",
["Review", "Compare"],
None,
"{index} | {all: true} → {rejected} (drop revisions without applying them: the open deck stays as it is; not an undo step)",
comparing,
reject
),
cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare),
cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)),
cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| {
@@ -137,7 +146,14 @@ fn reply(s: &mut Session, p: &Value) -> Result<Value> {
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let all = bool_or(p, "all", false);
let i = usize_param(p, "index");
let i = match p.get("index") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_u64()
.and_then(|v| usize::try_from(v).ok())
.ok_or_else(|| bad("comment.delete", format!("`index` is not a valid comment index: {v}")))?,
),
};
s.edit(|doc, sel| {
if all {
for sl in &mut doc.slides {
@@ -648,7 +664,7 @@ fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
fn revisions_json(st: &crate::DocState) -> Result<Value> {
let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?;
let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone());
Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position}))
Ok(json!({"name": name, "changes": crate::compare::pending(&st.doc, c), "byPosition": c.by_position}))
}
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
@@ -656,7 +672,7 @@ fn compare(s: &mut Session, p: &Value) -> Result<Value> {
let revised = super::file::open_presentation(&name, &bytes)?;
let st = s.doc_mut()?;
let by_position = crate::compare::unrelated(&st.doc, &revised);
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position });
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position, rejected: Default::default() });
s.ui_requests.push(UiRequest::Pane { id: "revisions".into() });
revisions_json(s.doc()?)
}
@@ -666,7 +682,8 @@ fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
}
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?;
let cmp = s.doc()?.compare.clone().ok_or_else(|| bad("review.accept", "not comparing"))?;
let rev = cmp.revised.clone();
let all = bool_param(p, "all").unwrap_or(false);
let index = usize_param(p, "index");
if !all && index.is_none() {
@@ -677,7 +694,7 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
// Each accept shifts indices, so the list is recomputed; bounded by the slide counts.
let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1);
for _ in 0..limit {
let changes = crate::compare::diff(doc, &rev, by_pos);
let changes = crate::compare::pending(doc, &cmp);
let c = if all {
changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1))
} else {
@@ -700,6 +717,24 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn reject(s: &mut Session, p: &Value) -> Result<Value> {
let all = bool_param(p, "all").unwrap_or(false);
let index = usize_param(p, "index");
if !all && index.is_none() {
return Err(bad("review.reject", "give `index` or `all`"));
}
let st = s.doc_mut()?;
let doc = st.doc.clone();
let c = st.compare.as_mut().ok_or_else(|| bad("review.reject", "not comparing"))?;
let pending = crate::compare::pending(&doc, c);
let picked: Vec<_> = if all { pending } else { pending.into_iter().nth(index.unwrap_or(0)).into_iter().collect() };
if picked.is_empty() {
return Err(bad("review.reject", "no such revision"));
}
c.rejected.extend(picked.iter().map(|ch| crate::compare::ChangeKey::of(&doc, ch)));
Ok(json!({"rejected": picked.len()}))
}
fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
s.doc_mut()?.compare = None;
ok()
@@ -793,4 +828,18 @@ mod tests {
s.execute("show.setup", &json!({"custom": ""})).unwrap();
assert!(s.active().unwrap().doc.show.custom_show.is_none());
}
#[test]
fn delete_rejects_an_invalid_index_and_keeps_the_comments() {
let mut s = Session::with_new();
s.execute("comment.add", &json!({"text": "a"})).unwrap();
s.execute("comment.add", &json!({"text": "b"})).unwrap();
for bad_index in [json!(-1), json!("2"), json!(1.5)] {
let r = s.execute("comment.delete", &json!({"index": bad_index}));
assert!(r.is_err(), "index {bad_index} gave {r:?}");
assert_eq!(s.active().unwrap().doc.slides[0].comments.len(), 2, "index {bad_index}");
}
s.execute("comment.delete", &json!({})).unwrap();
assert!(s.active().unwrap().doc.slides[0].comments.is_empty());
}
}
+133 -35
View File
@@ -107,14 +107,16 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
}
if closing {
let kind = tag_kind(&name);
if !kind.is_empty()
&& let Some(pos) = stack.iter().rposition(|e| e.0 == kind)
{
let (_, db, di, du) = stack[pos];
bold -= db;
italic -= di;
underline -= du;
stack.truncate(pos);
if !kind.is_empty() {
let search_start = stack.len().saturating_sub(64);
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0 == kind) {
let pos = search_start + offset;
let (_, db, di, du) = stack[pos];
bold -= db;
italic -= di;
underline -= du;
stack.truncate(pos);
}
}
if is_block(&name) {
new_para(&mut paragraphs);
@@ -130,7 +132,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
bold += db;
italic += di;
underline += du;
if !token.ends_with('/') {
if !token.ends_with('/') && stack.len() < 128 {
stack.push((tag_kind(&name), db, di, du));
} else {
bold -= db;
@@ -145,8 +147,13 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
continue;
}
let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len());
let text = decode_text(&html[i..end]);
let mut text = decode_text(&html[i..end]);
i = end;
// A space at the start of a paragraph, or after a space, does not render in HTML.
let after_space = paragraphs.last().and_then(|p| p.runs.last()).is_none_or(|r| r.text.ends_with(' '));
if after_space {
text = text.trim_start_matches(' ').to_string();
}
if text.is_empty() {
continue;
}
@@ -155,6 +162,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
while paragraphs.last().is_some_and(Paragraph::is_empty) && paragraphs.len() > 1 {
paragraphs.pop();
}
trim_end_space(&mut paragraphs);
if paragraphs.iter().all(Paragraph::is_empty) {
return None;
}
@@ -189,8 +197,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 +214,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> {
@@ -231,10 +248,19 @@ fn new_para(paragraphs: &mut Vec<Paragraph>) {
return;
}
if paragraphs.last().is_some_and(|p| !p.is_empty()) {
trim_end_space(paragraphs);
paragraphs.push(Paragraph::default());
}
}
/// Whitespace at the end of a paragraph does not render in HTML.
fn trim_end_space(paragraphs: &mut [Paragraph]) {
if let Some(run) = paragraphs.last_mut().and_then(|p| p.runs.last_mut()) {
let len = run.text.trim_end_matches(' ').len();
run.text.truncate(len);
}
}
fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: bool, italic: bool, underline: bool) {
if *runs >= MAX_RUNS {
return;
@@ -254,42 +280,69 @@ fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold:
*runs += 1;
}
/// Text with each run of HTML whitespace (spaces, tabs, line breaks) collapsed to one space.
fn collapse_space(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut space = false;
for c in s.chars() {
if matches!(c, ' ' | '\t' | '\n' | '\r') {
if !space {
out.push(' ');
}
space = true;
} else {
out.push(c);
space = false;
}
}
out
}
fn decode_text(raw: &str) -> String {
let mut out = String::new();
let mut rest = raw;
while let Some(at) = rest.find('&') {
out.push_str(&rest[..at].replace(['\n', '\r'], ""));
out.push_str(&collapse_space(&rest[..at]));
rest = &rest[at..];
let Some(end) = rest.find(';') else {
out.push_str(&rest.replace(['\n', '\r'], ""));
break;
let search_window = rest.len().min(32);
let Some(end) = rest[..search_window].find(';') else {
out.push('&');
rest = &rest[1..];
continue;
};
let entity = &rest[1..end];
rest = &rest[end + 1..];
match entity {
"amp" => out.push('&'),
"lt" => out.push('<'),
"gt" => out.push('>'),
"quot" => out.push('"'),
"apos" => out.push('\''),
"nbsp" => out.push(' '),
_ => {
let ch = entity.strip_prefix('#').and_then(numeric_char);
if let Some(c) = ch {
out.push(c);
}
}
let decoded = match entity {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some('\u{a0}'),
_ => entity.strip_prefix('#').and_then(numeric_char),
};
if let Some(c) = decoded {
out.push(c);
rest = &rest[end + 1..];
} else {
// Not a well-formed entity (e.g. a bare "R&D"): keep the '&' as typed.
out.push('&');
rest = &rest[1..];
}
}
if !rest.is_empty() && !rest.starts_with('&') {
out.push_str(&rest.replace(['\n', '\r'], ""));
out.push_str(&collapse_space(rest));
}
out
}
fn numeric_char(digits: &str) -> Option<char> {
let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X'));
let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? };
// `parse` and `from_str_radix` accept a leading '+', which is not a valid reference.
let (body, radix) = hex.map_or((digits, 10), |h| (h, 16));
if body.is_empty() || !body.chars().all(|c| c.is_digit(radix)) {
return None;
}
let n = u32::from_str_radix(body, radix).ok()?;
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
}
@@ -308,6 +361,27 @@ fn fold(s: &str) -> String {
mod tests {
use super::*;
#[test]
fn signed_numeric_entities_are_kept_as_typed() {
assert_eq!(decode_text("a&#+65;b"), "a&#+65;b");
assert_eq!(decode_text("a&#x+41;b"), "a&#x+41;b");
assert_eq!(decode_text("a&#65;b&#x41;"), "aAbA");
}
#[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");
@@ -327,6 +401,18 @@ mod tests {
assert!(last.props.underline.is_some());
}
#[test]
fn bare_ampersand_keeps_text_up_to_next_semicolon() {
let text = |html: &str| html_to_body(html).unwrap().text();
assert_eq!(text("<p>R&D costs; up</p>"), "R&D costs; up");
assert_eq!(text("<p>AT&T; Q&A</p>"), "AT&T; Q&A");
assert_eq!(text("<p>A &amp; B &#38; C &#x26; D</p>"), "A & B & C & D");
// Verify many bare ampersands do not cause quadratic scan freeze
let hostile = format!("<p>{}</p>", "&".repeat(2000));
assert_eq!(text(&hostile), "&".repeat(2000));
}
#[test]
fn pages_style_and_escapes() {
let html = "<html><head><style>p{}</style></head><body><p><span style=\"font-weight: bold\">A &amp; B</span></p><p><span style=\"font-style: italic\">C</span></p></body></html>";
@@ -338,6 +424,18 @@ mod tests {
assert!(!same_words(&body.text(), "other"));
}
#[test]
fn a_newline_in_html_text_is_a_space() {
assert_eq!(decode_text("Hello\nworld"), "Hello world");
let body = html_to_body("<p>Hello\r\n world</p>").unwrap();
assert_eq!(body.text(), "Hello world");
let body = html_to_body("<p>\n<b>Hello</b>\n<i>world</i>\n</p>\n<p>next</p>").unwrap();
assert_eq!(body.text(), "Hello world\nnext");
assert!(same_words(&body.text(), "Hello world\nnext"));
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
assert_eq!(body.paragraphs[0].runs.last().unwrap().props.italic, Some(true));
}
#[test]
fn a_huge_fragment_is_refused() {
let html = "x".repeat(MAX_HTML + 1);
+159 -38
View File
@@ -1,6 +1,8 @@
//! Shape Format: position and size, rotation, flips, geometry, fill, outline, effects, styles,
//! names and alt text.
use std::collections::HashMap;
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, PictureMode, Reflection, Shadow};
use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle};
@@ -85,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Fill",
["Shape Format", "Shape Styles"],
None,
"{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill",
"{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
has_selection,
fill
),
@@ -94,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Outline",
["Shape Format", "Shape Styles"],
None,
"{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}",
"{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}; a group also formats its members",
has_selection,
line
),
@@ -169,13 +171,24 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Keeps a side length in `0..=MAX_PT` so a huge value cannot exhaust memory when rendering.
/// An overflow to infinity lands on the limit; NaN becomes 0.
fn clamp_side(v: f64) -> f64 {
if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_PT) }
}
/// Keeps a coordinate within `±POS_LIMIT`.
fn clamp_pos(v: f64) -> f64 {
if v.is_nan() { 0.0 } else { v.clamp(-POS_LIMIT, POS_LIMIT) }
}
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
edit_geometry(s, p, "shape.move", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0);
x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0);
x.x = clamp_pos(ax.unwrap_or(x.x) + dx.unwrap_or(0.0));
x.y = clamp_pos(ay.unwrap_or(x.y) + dy.unwrap_or(0.0));
}
Ok(())
})
@@ -202,10 +215,12 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
x.h = h.unwrap_or(x.h);
}
}
x.w = clamp_side(x.w);
x.h = clamp_side(x.h);
// Rotated shapes resize about their centre; others keep the top-left.
if x.rot != 0.0 {
x.x = cx - x.w / 2.0;
x.y = cy - x.h / 2.0;
x.x = clamp_pos(cx - x.w / 2.0);
x.y = clamp_pos(cy - x.h / 2.0);
}
}
Ok(())
@@ -215,10 +230,10 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
edit_geometry(s, p, "shape.setBounds", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
x.x = f64_param(p, "x").unwrap_or(x.x);
x.y = f64_param(p, "y").unwrap_or(x.y);
x.w = f64_param(p, "w").unwrap_or(x.w).max(0.0);
x.h = f64_param(p, "h").unwrap_or(x.h).max(0.0);
x.x = clamp_pos(f64_param(p, "x").unwrap_or(x.x));
x.y = clamp_pos(f64_param(p, "y").unwrap_or(x.y));
x.w = clamp_side(f64_param(p, "w").unwrap_or(x.w));
x.h = clamp_side(f64_param(p, "h").unwrap_or(x.h));
}
Ok(())
})
@@ -406,6 +421,8 @@ fn flip(s: &mut Session, p: &Value) -> Result<Value> {
} else {
x.flip_h = !x.flip_h;
}
// `Xfrm::affine` flips first and rotates second; a true mirror also negates the rotation.
x.rot = (360.0 - x.rot_norm()) % 360.0;
}
Ok(())
})
@@ -518,6 +535,24 @@ pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
}
/// Fill and outline apply to a group's visible members as well as its container. Other edits
/// (notably geometry) still target only the selected shape. The whole traversal stays in one edit.
fn edit_group_appearance(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
fn apply(sh: &mut deckcraft_model::Shape, depth: usize, f: &dyn Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<()> {
if depth > 64 {
return Ok(());
}
f(sh)?;
if let Some(children) = sh.children_mut() {
for child in children {
apply(child, depth + 1, f)?;
}
}
Ok(())
}
edit_shapes(s, p, cmd, |sh| apply(sh, 0, &f))
}
fn fill(s: &mut Session, p: &Value) -> Result<Value> {
let new_fill: Option<Option<Fill>> = if bool_or(p, "reset", false) {
Some(None)
@@ -550,27 +585,26 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
}
// Transparency on an inherited (style) fill needs the effective colour.
let st = s.doc()?;
let ctx_fill: Vec<(deckcraft_model::ShapeId, Option<Fill>)> = {
let ctx_fill = {
let ctx = super::text::ctx_for(&st.doc, &st.selection);
targets(s, p)?
.into_iter()
.filter_map(|id| {
st.shape(id).map(|sh| {
(
id,
ctx.as_ref().and_then(|c| {
let (f, ph) = deckcraft_model::resolve::fill(c, sh);
f.map(|f| match f {
Fill::Solid { color } => Fill::solid(ColorRef::rgb(c.color(&color, ph))),
other => other,
})
}),
)
})
})
.collect()
let mut fills = HashMap::new();
for id in targets(s, p)? {
if let Some(sh) = st.shape(id) {
deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| {
let fill = ctx.as_ref().and_then(|c| {
let (f, ph) = deckcraft_model::resolve::fill(c, sh);
f.map(|f| match f {
Fill::Solid { color } => Fill::solid(ColorRef::rgb(c.color(&color, ph))),
other => other,
})
});
fills.insert(sh.id, fill);
});
}
}
fills
};
edit_shapes(s, p, "shape.fill", |sh| {
edit_group_appearance(s, p, "shape.fill", |sh| {
if let Some(f) = &new_fill {
sh.fill.clone_from(f);
}
@@ -583,7 +617,7 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(());
}
if let Some(t) = transparency {
let base = sh.fill.clone().or_else(|| ctx_fill.iter().find(|(i, _)| *i == sh.id).and_then(|(_, f)| f.clone()));
let base = sh.fill.clone().or_else(|| ctx_fill.get(&sh.id).cloned().flatten());
if let Some(Fill::Solid { mut color }) = base {
color.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
if t > 0.0 {
@@ -675,7 +709,7 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {
"miter" => deckcraft_model::style::LineJoin::Miter,
_ => deckcraft_model::style::LineJoin::Round,
});
edit_shapes(s, p, "shape.line", |sh| {
edit_group_appearance(s, p, "shape.line", |sh| {
if reset {
sh.line = None;
return Ok(());
@@ -1150,19 +1184,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 +1218,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");
@@ -1490,6 +1522,95 @@ mod tests {
use super::*;
use serde_json::json;
fn nested_group() -> (Session, Vec<ShapeId>, ShapeId, ShapeId) {
let mut s = Session::with_new();
let mut ids = vec![];
for i in 0..4 {
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40 + i * 100, 100, 80, 60]})).unwrap()["id"].as_u64().unwrap();
s.execute("shape.line", &json!({"width": i + 1, "dash": "dash"})).unwrap();
ids.push(ShapeId(id as u32));
}
let group = |s: &mut Session, ids: &[ShapeId]| {
s.execute("edit.select", &json!({"ids": ids})).unwrap();
ShapeId(s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap() as u32)
};
let inner = group(&mut s, &ids[..2]);
let outer = group(&mut s, &[inner, ids[2]]);
(s, ids[..3].to_vec(), outer, ids[3])
}
#[test]
fn group_fill_reaches_nested_members_in_one_undo_step() {
let (mut s, members, group, outside) = nested_group();
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
let untouched = serde_json::to_value(s.doc().unwrap().shape(outside)).unwrap();
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
for id in &members {
assert_eq!(s.doc().unwrap().shape(*id).unwrap().fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 0, 0)))));
}
assert_eq!(s.doc().unwrap().selection.shapes, vec![group]);
assert_eq!(serde_json::to_value(s.doc().unwrap().shape(outside)).unwrap(), untouched);
let filled = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), filled);
s.execute("shape.fill", &json!({"none": true})).unwrap();
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().fill == Some(Fill::None)));
s.execute("shape.fill", &json!({"reset": true})).unwrap();
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().fill.is_none()));
}
#[test]
fn group_outline_reaches_members_without_resetting_their_other_properties() {
let (mut s, members, group, outside) = nested_group();
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
// Explicit ids work even when another object is selected.
s.execute("edit.select", &json!({"ids": [outside]})).unwrap();
s.execute("shape.line", &json!({"ids": [group], "color": "#00ff00"})).unwrap();
for (i, id) in members.iter().enumerate() {
let line = s.doc().unwrap().shape(*id).unwrap().line.as_ref().unwrap();
assert_eq!(line.fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 255, 0)))));
assert_eq!(line.width, Some((i + 1) as f64));
assert_eq!(line.dash, Some(Dash::Preset { name: "dash".into() }));
}
assert_eq!(s.doc().unwrap().selection.shapes, vec![outside]);
let outlined = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), outlined);
s.execute("shape.line", &json!({"ids": [group], "none": true})).unwrap();
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().line.as_ref().unwrap().fill == Some(Fill::None)));
s.execute("shape.line", &json!({"ids": [group], "reset": true})).unwrap();
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().line.is_none()));
}
#[test]
fn group_fill_transparency_keeps_each_members_inherited_colour() {
let (mut s, members, group, _) = nested_group();
s.execute("shape.fill", &json!({"ids": [members[1]], "color": "#00ff00"})).unwrap();
let colours: Vec<_> = {
let st = s.doc().unwrap();
let ctx = super::super::text::ctx_for(&st.doc, &st.selection).unwrap();
members
.iter()
.map(|id| {
let (fill, ph) = deckcraft_model::resolve::fill(&ctx, st.shape(*id).unwrap());
let Some(Fill::Solid { color }) = fill else { panic!("solid theme fill") };
ctx.color(&color, ph)
})
.collect()
};
s.execute("shape.fill", &json!({"ids": [group], "transparency": 0.5})).unwrap();
for (id, colour) in members.iter().zip(colours) {
let mut expected = ColorRef::rgb(colour);
expected.mods.push(ColorTransform::Alpha(50000));
assert_eq!(s.doc().unwrap().shape(*id).unwrap().fill, Some(Fill::solid(expected)));
}
}
fn rotation(s: &Session) -> f64 {
let st = s.active().unwrap();
let id = st.selection.shapes[0];
+79 -10
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,9 +158,28 @@ 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);
let i = opt_usize_param(p, "index", "slide")?.unwrap_or(st.selection.slide);
if i >= st.doc.slides.len() {
return Err(bad("slide", format!("no slide {i}")));
}
@@ -168,7 +187,7 @@ fn index_of(s: &Session, p: &Value) -> Result<usize> {
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let indices: Vec<usize> = match usize_param(p, "index") {
let indices: Vec<usize> = match opt_usize_param(p, "index", "slide.duplicate")? {
Some(i) => vec![i],
None => {
let st = s.doc()?;
@@ -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));
@@ -218,7 +240,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
if let Some(a) = p.get("ids").and_then(Value::as_array) {
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(SlideId).collect()
} else if let Some(i) = usize_param(p, "index") {
} else if let Some(i) = opt_usize_param(p, "index", "slide.delete")? {
st.doc.slides.get(i).map(|x| vec![x.id]).unwrap_or_default()
} else if !st.selection.slides.is_empty() {
st.selection.slides.clone()
@@ -244,7 +266,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
let from = usize_param(p, "from").unwrap_or(s.doc()?.selection.slide);
let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide);
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
s.edit(|doc, sel| {
if from >= doc.slides.len() {
@@ -351,7 +373,7 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
}
fn hide(s: &mut Session, p: &Value) -> Result<Value> {
let targets: Vec<usize> = match usize_param(p, "index") {
let targets: Vec<usize> = match opt_usize_param(p, "index", "slide.hide")? {
Some(i) => vec![i],
None => {
let st = s.doc()?;
@@ -420,7 +442,7 @@ pub fn apply_layout(doc: &mut Presentation, index: usize, layout: LayoutId) -> R
fn set_layout(s: &mut Session, p: &Value) -> Result<Value> {
let key = p.get("layout").cloned().ok_or_else(|| bad("slide.layout", "missing `layout`"))?;
let targets: Vec<usize> = match usize_param(p, "index") {
let targets: Vec<usize> = match opt_usize_param(p, "index", "slide.layout")? {
Some(i) => vec![i],
None => {
let st = s.doc()?;
@@ -509,7 +531,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
}
fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
let at = usize_param(p, "at").unwrap_or(s.doc()?.selection.slide);
let at = opt_usize_param(p, "at", "section.add")?.unwrap_or(s.doc()?.selection.slide);
let name = str_param(p, "name").unwrap_or("Untitled Section").to_string();
s.edit(|doc, _| {
let at = at.min(doc.slides.len().saturating_sub(1));
@@ -517,9 +539,9 @@ fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
if doc.sections.is_empty() {
// The slides before `at` go into a "Default Section".
if at > 0 {
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(|x| x.to_vec()).unwrap_or_default() });
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(<[_]>::to_vec).unwrap_or_default() });
}
doc.sections.push(Section { name, slides: ids.get(at..).map(|x| x.to_vec()).unwrap_or_default() });
doc.sections.push(Section { name, slides: ids.get(at..).map(<[_]>::to_vec).unwrap_or_default() });
} else {
let Some(start) = ids.get(at).copied() else { return Err(bad("section.add", "no slide")) };
let si = doc.section_of(start).unwrap_or(0);
@@ -605,6 +627,17 @@ fn from_outline(s: &mut Session, p: &Value) -> Result<Value> {
for (k, sl) in new.into_iter().enumerate() {
doc.slides.insert(at + k, sl);
}
// Join the section of the slide before, as slide.new does.
let ids: Vec<SlideId> = doc.slides.iter().skip(at).take(n).map(|x| x.id).collect();
let prev = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
let section = prev.and_then(|p| doc.section_of(p));
if let Some(sec) = section.and_then(|si| doc.sections.get_mut(si)) {
let pos = prev.and_then(|p| sec.slides.iter().position(|x| *x == p)).map(|k| k + 1).unwrap_or(sec.slides.len());
let pos = pos.min(sec.slides.len());
sec.slides.splice(pos..pos, ids);
} else if let Some(first) = doc.sections.first_mut() {
first.slides.splice(0..0, ids);
}
Ok(json!({"slides": n}))
})
}
@@ -615,6 +648,42 @@ mod tests {
use crate::Session;
#[test]
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
let mut s = Session::with_new();
while s.doc().expect("doc").doc.slides.len() < 3 {
s.execute("slide.new", &json!({})).expect("new slide");
}
for bad in [json!(-1), json!(1.5)] {
for (cmd, p) in [
("slide.delete", json!({"index": bad})),
("slide.duplicate", json!({"index": bad})),
("slide.move", json!({"from": bad, "to": 0})),
("section.add", json!({"at": bad})),
] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
assert_eq!(s.doc().expect("doc").doc.slides.len(), 3, "{cmd} {p}");
}
}
}
#[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;
}
}
+136 -13
View File
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AutoCorrect Options",
["Tools"],
None,
"{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
"{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
always,
autocorrect_options
),
@@ -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)),
@@ -362,7 +417,8 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
let c = ed::insert(body, at, &text, props);
t.anchor = c;
t.caret = c;
for pi in at.0..=c.0 {
let last = body.paragraphs.len().saturating_sub(1);
for pi in at.0..=c.0.min(last) {
if let Some(para) = body.paragraphs.get_mut(pi) {
promote_rtl_if_needed(para, &inherited);
}
@@ -600,7 +656,12 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
} else {
correct_word(&s.prefs, &word, sentence_start, typing)
};
let Some(rep) = rep else { return Ok(()) };
let Some(rep) = rep else {
if typing && s.prefs.ac_ordinals && is_ordinal(&word) {
return superscript_suffix(s, &t, word_end);
}
return Ok(());
};
let delta = rep.chars().count() as isize - word.chars().count() as isize;
let st = s.doc_mut()?;
let mut d = (*st.doc).clone();
@@ -619,6 +680,43 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
Ok(())
}
/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat
/// superscripts it.
fn is_ordinal(word: &str) -> bool {
let digits = word.trim_end_matches(|c: char| c.is_ascii_lowercase());
let suffix = word.get(digits.len()..).unwrap_or_default();
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) || suffix.len() != 2 {
return false;
}
let d = digits.as_bytes();
let teen = d.len() >= 2 && d.get(d.len() - 2) == Some(&b'1');
let want = match d.last() {
_ if teen => "th",
Some(b'1') => "st",
Some(b'2') => "nd",
Some(b'3') => "rd",
_ => "th",
};
suffix == want
}
/// Superscript the two letters before `end` in the caret's paragraph (an ordinal's suffix).
fn superscript_suffix(s: &mut Session, t: &crate::TextSel, end: usize) -> Result<()> {
let (a, b) = ((t.caret.0, end.saturating_sub(2)), (t.caret.0, end));
let st = s.doc_mut()?;
if body_of(st, t).is_none_or(|body| ed::props_at(body, b).baseline.unwrap_or(0.0) > 0.0) {
return Ok(());
}
let mut d = (*st.doc).clone();
let sel = st.selection.clone();
if let Some(body) = body_mut(&mut d, &sel, t) {
ed::format(body, a, b, &|r| r.baseline = Some(0.3));
}
st.doc = Arc::new(d);
st.revision += 1;
Ok(())
}
fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
let pr = &mut s.prefs;
for (key, slot) in [
@@ -628,6 +726,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
("days", &mut pr.ac_days),
("capsLock", &mut pr.ac_caps_lock),
("fractions", &mut pr.ac_fractions),
("ordinals", &mut pr.ac_ordinals),
] {
if let Some(v) = p.get(key).and_then(Value::as_bool) {
*slot = v;
@@ -674,7 +773,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({
"on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps,
"days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions,
"entries": list,
"ordinals": pr.ac_ordinals, "entries": list,
}))
}
@@ -686,7 +785,8 @@ pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result<Value> {
let c = ed::insert_body(body, at, src);
t.anchor = c;
t.caret = c;
for pi in at.0..=c.0 {
let last = body.paragraphs.len().saturating_sub(1);
for pi in at.0..=c.0.min(last) {
if let Some(para) = body.paragraphs.get_mut(pi) {
promote_rtl_if_needed(para, &inherited);
}
@@ -836,7 +936,7 @@ pub fn cell_range_underlines(
return vec![];
};
let body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell);
let tmp = deckcraft_model::Shape { text: Some(body.clone()), ..Default::default() };
let tmp = deckcraft_model::Shape::table_cell(body.clone());
let l = deckcraft_text::layout(
&ctx,
&tmp,
@@ -905,7 +1005,7 @@ fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option<C
if body.paragraphs.is_empty() {
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
}
let tmp = deckcraft_model::Shape { text: Some(body.clone()), ..Default::default() };
let tmp = deckcraft_model::Shape::table_cell(body.clone());
let layout = deckcraft_text::layout(
&ctx,
&tmp,
@@ -1026,7 +1126,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
}
None => match to.as_str() {
"up" => (caret.0.saturating_sub(1), caret.1),
"down" => ((caret.0 + 1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
"down" => (caret.0.saturating_add(1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
"lineStart" => (caret.0, 0),
_ => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
},
@@ -1091,9 +1191,11 @@ fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
let at = pos_param(p, "at").unwrap_or(t.caret);
let body = body_of(st, &t);
// Clamp to the last paragraph so a huge index can neither overflow nor select past the body.
let at = (at.0.min(body.map_or(0, |b| b.paragraphs.len().saturating_sub(1))), at.1);
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
let end = if body.is_some_and(|b| at.0.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) };
s.select(|_, sel| {
if let Some(t) = sel.text.as_mut() {
t.anchor = (at.0, 0);
@@ -1273,7 +1375,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
if body.paragraphs.is_empty() {
body.paragraphs.push(Default::default());
}
for pi in a.0..=b.0 {
let last = body.paragraphs.len().saturating_sub(1);
for pi in a.0..=b.0.min(last) {
if let Some(p) = body.paragraphs.get_mut(pi) {
f(p);
}
@@ -1421,3 +1524,23 @@ mod replace_font_tests {
assert!(s.execute("font.replace", &json!({"from": "Inter"})).is_err());
}
}
#[cfg(test)]
mod select_paragraph_tests {
use serde_json::json;
use crate::Session;
#[test]
fn select_paragraph_huge_at() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "hello"})).unwrap();
s.execute("text.edit", &json!({})).unwrap();
let r = s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]}));
assert!(r.is_ok(), "{r:?}");
let t = s.doc().unwrap().selection.text.clone().unwrap();
// Clamped to the last (only) paragraph, never past the end of the body.
assert_eq!((t.anchor, t.caret), ((0, 0), (0, 5)));
}
}
+155 -2
View File
@@ -1,5 +1,5 @@
//! Review › Compare: the differences between the open presentation and a revised copy of it, slide
//! by slide, and accepting a revised slide into the open one.
//! by slide, and accepting a revised slide into the open one or rejecting it.
//!
//! Slides match by id (a revised copy of the same deck keeps them); when the two decks share no
//! slide id at all, by position. Shapes match by id, then by name.
@@ -18,6 +18,54 @@ pub struct Compare {
/// Slides match by position (the decks shared no slide id when the compare started); fixed
/// for the session so accepting slides doesn't switch the matching half way.
pub by_position: bool,
/// Revisions rejected: no longer listed and never accepted, the open deck left as it is.
pub rejected: HashSet<ChangeKey>,
}
/// What a revision is about, stable while slides move: the open slide (for changed and deleted)
/// and the revised slide (for changed and inserted).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ChangeKey {
kind: ChangeKind,
open: Option<SlideId>,
revised: Option<usize>,
}
impl ChangeKey {
pub fn of(doc: &Presentation, change: &Change) -> ChangeKey {
let open = match change.kind {
ChangeKind::Inserted => None,
ChangeKind::Changed | ChangeKind::Deleted => doc.slides.get(change.slide).map(|s| s.id),
};
ChangeKey { kind: change.kind, open, revised: change.revised }
}
}
/// The revisions still to review: [`diff`] without the rejected ones.
pub fn pending(doc: &Presentation, c: &Compare) -> Vec<Change> {
let mut out = diff(doc, &c.revised, c.by_position);
out.retain(|ch| !is_rejected(doc, c, ch));
out
}
/// Whether `ch` was rejected. Positional matching re-pairs slides by index when the open deck gains
/// or loses a slide, which changes the open half of the key; the revised index stays put, so any
/// rejected key for the same revised slide counts. An open slide pushed past the end of the revised
/// deck turns into a deletion; it stays kept when a rejected key names it.
fn is_rejected(doc: &Presentation, c: &Compare, ch: &Change) -> bool {
if c.rejected.contains(&ChangeKey::of(doc, ch)) {
return true;
}
if !c.by_position {
return false;
}
match ch.kind {
ChangeKind::Deleted => {
let open = doc.slides.get(ch.slide).map(|s| s.id);
open.is_some() && c.rejected.iter().any(|k| k.open == open)
}
_ => ch.revised.is_some() && c.rejected.iter().any(|k| k.revised == ch.revised),
}
}
/// Whether `cur` and `rev` share no slide id (then slides match by position).
@@ -26,7 +74,7 @@ pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool {
!cur.slides.iter().any(|s| ids.contains(&s.id))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ChangeKind {
/// Only in the revised deck.
@@ -434,6 +482,50 @@ mod tests {
assert!(s.execute("review.accept", &json!({"all": true})).is_err());
}
#[test]
fn reject_keeps_the_open_deck_and_accept_all_skips_rejected_revisions() {
let (mut s, rev, n) = decks();
s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
let ins = before.iter().position(|c| c["kind"] == "inserted").unwrap();
let doc = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("review.reject", &json!({"index": ins})).unwrap()["rejected"], 1);
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc), "rejecting leaves the open deck as it is");
let left = s.execute("review.revisions", &json!({})).unwrap();
assert_eq!(kinds(&left).len(), before.len() - 1);
assert!(!kinds(&left).contains(&"inserted".into()), "{left}");
// Accept All takes the rest, never the rejected slide.
s.execute("review.accept", &json!({"all": true})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.doc.slides.len(), n - 1, "the deleted slide goes, the rejected insertion never comes");
assert!(!st.doc.slides.iter().any(|x| super::slide_title(x) == "Brand new"));
assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc));
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "rejecting is not an undo step");
// Reject All: nothing left to review, the deck as it was; Accept All then has nothing to take.
assert_eq!(s.execute("review.reject", &json!({"all": true})).unwrap()["rejected"], before.len() - 1);
assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty());
assert!(s.execute("review.accept", &json!({"all": true})).is_err());
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc));
assert!(s.execute("review.reject", &json!({"all": true})).is_err(), "nothing left to reject");
assert!(s.execute("review.reject", &json!({})).is_err());
s.execute("review.endCompare", &json!({})).unwrap();
assert!(s.execute("review.reject", &json!({"index": 0})).is_err());
}
#[test]
fn rejecting_one_revision_keeps_the_others_in_order() {
let (mut s, rev, _) = decks();
s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
assert!(s.execute("review.reject", &json!({"index": before.len()})).is_err());
s.execute("review.reject", &json!({"index": 0})).unwrap();
let left = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
assert_eq!(left.as_slice(), &before[1..], "the rejected one goes, the others stay in order");
}
#[test]
fn unrelated_decks_match_by_position_and_bad_input_fails() {
let mut s = Session::with_new();
@@ -452,4 +544,65 @@ mod tests {
assert!(s.execute("review.compare", &json!({})).is_err());
assert!(s.execute("review.accept", &json!({"index": 999})).is_err());
}
fn slide_texts(s: &Session) -> Vec<String> {
s.doc().unwrap().doc.slides.iter().map(|sl| sl.shapes.iter().filter_map(|x| x.text.as_ref()).map(|t| t.text()).collect::<String>()).collect()
}
/// By position, a rejected revision stays rejected when a slide is inserted into the open deck
/// and the same revised slide now pairs with a different open slide.
#[test]
fn a_rejected_positional_revision_stays_rejected_after_a_slide_is_inserted() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "title", "title": "A"})).unwrap();
let mut other = Session::with_new();
other.execute("slide.new", &json!({"layout": "title", "title": "B"})).unwrap();
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap();
assert_eq!(v["byPosition"], true, "{v}");
let changed = |v: &Value| -> Vec<u64> {
v["changes"].as_array().unwrap().iter().filter(|c| c["kind"] == "changed").map(|c| c["revised"].as_u64().unwrap()).collect()
};
let revised = changed(&v);
let last = *revised.last().expect("a changed slide");
let at = v["changes"].as_array().unwrap().iter().position(|c| c["revised"] == last).unwrap();
s.execute("review.reject", &json!({"index": at})).unwrap();
// A new slide at the front shifts every open slide down by one.
s.execute("slide.new", &json!({"layout": "title", "title": "N"})).unwrap();
let n_at = slide_texts(&s).iter().position(|t| t.contains('N')).unwrap();
s.execute("slide.move", &json!({"from": n_at, "to": 0})).unwrap();
assert_eq!(slide_texts(&s).iter().position(|t| t.contains('N')), Some(0));
let now = s.execute("review.revisions", &json!({})).unwrap();
assert!(!changed(&now).contains(&last), "{now}");
s.execute("review.accept", &json!({"all": true})).unwrap();
let texts = slide_texts(&s);
assert!(!texts.iter().any(|t| t.contains('B')), "{texts:?}");
}
/// By position, inserting a slide before a rejected pair pushes the pair's open slide past the
/// end of the revised deck; it must not turn into a deletion that Accept All carries out.
#[test]
fn a_rejected_positional_revision_does_not_become_a_deletion_when_a_slide_is_inserted() {
let mut s = Session::with_new();
let mut other = Session::with_new();
for t in ["A", "X"] {
s.execute("slide.new", &json!({"layout": "title", "title": t})).unwrap();
}
for t in ["B", "Y"] {
other.execute("slide.new", &json!({"layout": "title", "title": t})).unwrap();
}
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap();
assert_eq!(v["byPosition"], true, "{v}");
let changes = v["changes"].as_array().unwrap();
let last = changes.iter().filter(|c| c["kind"] == "changed").map(|c| c["revised"].as_u64().unwrap()).max().expect("a changed slide");
let at = changes.iter().position(|c| c["kind"] == "changed" && c["revised"] == last).unwrap();
let open_id = s.doc().unwrap().doc.slides[last as usize].id;
s.execute("review.reject", &json!({"index": at})).unwrap();
s.execute("slide.new", &json!({"layout": "title", "title": "N"})).unwrap();
let n_at = slide_texts(&s).iter().position(|t| t.contains('N')).unwrap();
s.execute("slide.move", &json!({"from": n_at, "to": 0})).unwrap();
s.execute("review.accept", &json!({"all": true})).unwrap();
assert!(s.doc().unwrap().doc.slides.iter().any(|sl| sl.id == open_id), "the open slide whose revision was rejected was deleted");
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session};
pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.map(ToString::to_string)
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".into())
}
+1 -1
View File
@@ -60,7 +60,7 @@ pub fn stroke_width(icon: &Icon, w: f64, h: f64) -> f64 {
}
fn parsed() -> &'static HashMap<&'static str, BezPath> {
static CACHE: OnceLock<HashMap<&'static str, BezPath>> = OnceLock::new();
static CACHE: OnceLock<HashMap<&str, BezPath>> = OnceLock::new();
CACHE.get_or_init(|| {
let mut m = HashMap::new();
for icon in ICONS {
+59 -5
View File
@@ -8,6 +8,14 @@
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
crate::file_access::check_read(path)?;
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
if !meta.is_file() {
return Err(format!("{path}: not a regular file"));
}
const MAX_FILE_SIZE: u64 = 2 << 30; // 2 GiB
if meta.len() > MAX_FILE_SIZE {
return Err(format!("{path}: file size {} exceeds 2 GiB limit", meta.len()));
}
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
}
@@ -28,21 +36,29 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
static COUNTER: AtomicU64 = AtomicU64::new(0);
crate::file_access::check_write(path)?;
let err = |e: std::io::Error| format!("{path}: {e}");
// A uniquely named temporary sibling, created exclusively (never following or reusing an
// existing file or symlink), is renamed over the destination: concurrent saves never share
// temporary bytes.
let target_path = match std::fs::canonicalize(path) {
Ok(canonical) => canonical,
Err(_) => std::path::PathBuf::from(path),
};
let target_str = target_path.to_str().unwrap_or(path);
let existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions());
let mut attempts = 0;
let (tmp, mut file) = loop {
let tmp = format!("{path}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
let tmp = format!("{target_str}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(f) => break (tmp, f),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
Err(e) => return Err(err(e)),
}
};
if let Some(perms) = existing_perms {
let _ = file.set_permissions(perms);
}
let written = file.write_all(bytes).and_then(|()| file.sync_all());
drop(file);
let result = written.and_then(|()| std::fs::rename(&tmp, path));
let result = written.and_then(|()| std::fs::rename(&tmp, &target_path));
if let Err(e) = result {
let _ = std::fs::remove_file(&tmp);
return Err(err(e));
@@ -67,3 +83,41 @@ pub fn file_len(path: &str) -> Option<u64> {
pub fn file_len(_path: &str) -> Option<u64> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_file_rejects_directory() {
let dir = std::env::temp_dir();
let res = read_file(dir.to_str().unwrap());
assert!(res.is_err());
}
#[test]
fn write_file_preserves_permissions_and_writes_content() {
let tmp_dir = std::env::temp_dir().join("deckcraft_io_test");
let _ = std::fs::create_dir_all(&tmp_dir);
let test_file = tmp_dir.join("test_write.txt");
let path_str = test_file.to_str().unwrap();
assert!(write_file(path_str, b"hello world").is_ok());
let read = read_file(path_str).unwrap();
assert_eq!(read, b"hello world");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o640);
std::fs::set_permissions(&test_file, perms).unwrap();
assert!(write_file(path_str, b"updated world").is_ok());
let new_meta = std::fs::metadata(&test_file).unwrap();
assert_eq!(new_meta.permissions().mode() & 0o777, 0o640);
}
let _ = std::fs::remove_file(&test_file);
let _ = std::fs::remove_dir(&tmp_dir);
}
}
+1
View File
@@ -60,6 +60,7 @@ impl Session {
if self.tool.edit_points.is_some() && matches!(k, "enter" | "return" | "escape") {
self.tool.edit_points = None;
self.tool.edit_vertex = None;
self.tool.edit_segment = None;
return Ok(json!({"handled": true, "command": "shape.editPoints"}));
}
if editing && let Some((id, params)) = self.text_key(k, mods) {
+7 -1
View File
@@ -222,6 +222,9 @@ pub struct DocState {
pub composition: Option<Composition>,
/// Review › Compare in progress: the revised copy.
pub compare: Option<compare::Compare>,
/// The last Duplicate: where it happened and each original with its copy, so duplicating the
/// copies again repeats how far they were moved, as in PowerPoint (#966).
pub last_duplicate: Option<(usize, Target, Vec<(ShapeId, ShapeId)>)>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
@@ -249,6 +252,7 @@ impl DocState {
missing_fonts: Vec::new(),
composition: None,
compare: None,
last_duplicate: None,
}
}
pub fn is_dirty(&self) -> bool {
@@ -324,12 +328,13 @@ pub struct Prefs {
pub smart_quotes: bool,
/// AutoCorrect options (Tools ▸ AutoCorrect): fix TWo INitial CApitals, capitalize the first
/// letter of sentences and the names of days, fix accidental cAPS lOCK, and replace 1/2, 1/4
/// and 3/4 with fraction characters.
/// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) as you type.
pub ac_two_initials: bool,
pub ac_sentence_caps: bool,
pub ac_days: bool,
pub ac_caps_lock: bool,
pub ac_fractions: bool,
pub ac_ordinals: bool,
/// Your own "replace … with …" entries; they win over the built-in list.
pub ac_entries: Vec<(String, String)>,
/// Built-in entries you deleted.
@@ -354,6 +359,7 @@ impl Default for Prefs {
ac_days: true,
ac_caps_lock: true,
ac_fractions: true,
ac_ordinals: true,
ac_entries: Vec::new(),
ac_removed: Vec::new(),
author: "Presenter".into(),
+24 -4
View File
@@ -44,6 +44,11 @@ pub fn instance_id() -> u64 {
*ID.get_or_init(|| {
use std::hash::{BuildHasher, Hasher};
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
// No PIDs and no std::time on wasm (`std::process::id()` and
// `SystemTime::now()` panic there): the browser clock stands in.
#[cfg(target_arch = "wasm32")]
h.write_u64(js_sys::Date::now() as u64);
#[cfg(not(target_arch = "wasm32"))]
h.write_u32(std::process::id());
#[cfg(not(target_arch = "wasm32"))]
if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
@@ -80,8 +85,20 @@ fn owner_alive(dir: &Path, owner: u64) -> bool {
/// Write `bytes` to `path` through a temporary file and a rename, so a crash never leaves it torn.
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
let tmp = path.with_extension(format!("{}.tmp", path.extension().and_then(|e| e.to_str()).unwrap_or("")));
std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(&tmp).map_err(|e| EngineError::Other(format!("{}: {e}", tmp.display())))?;
file.write_all(bytes).and_then(|()| file.sync_all()).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
EngineError::Other(format!("{}: {e}", path.display()))
})
}
/// Presentations reopened per start; the rest stay on disk for the next one, so a folder
@@ -147,13 +164,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
let Ok(rd) = std::fs::read_dir(dir) else { return out };
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") {
if p.extension().and_then(std::ffi::OsStr::to_str) != Some("deckcraft") {
continue;
}
if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) {
continue;
}
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::<u64>().ok()) else { continue };
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES)
.and_then(|t| serde_json::from_slice::<Value>(&t).ok())
@@ -311,7 +328,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();
File diff suppressed because it is too large Load Diff
+144
View File
@@ -437,3 +437,147 @@ fn select_into_another_table_cell_settles_the_composition() {
let cell = |c| t.cell(0, c).map(|x| x.text.text()).unwrap_or_default();
assert_eq!((cell(0), cell(1)), ("abhello".to_string(), "X0123456789".to_string()));
}
/// Bold the first paragraph, select part of it and arm the Format Painter.
fn painter_armed(sticky: bool) -> (Session, u32) {
let (mut s, id) = editing("Hello\nworld", (0, 0));
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();
s.execute("format.bold", &json!({"on": true})).unwrap();
s.execute("format.painter", &json!({"sticky": sticky})).unwrap();
(s, id)
}
fn bold_at(s: &mut Session, at: Pos) -> bool {
s.execute("text.select", &json!({"anchor": [at.0, at.1], "caret": [at.0, at.1 + 1]})).unwrap();
s.execute("format.state", &json!({})).unwrap()["bold"] == true
}
fn pointer(s: &mut Session, kind: crate::tools::PointerKind, x: f64, y: f64) {
s.pointer(crate::tools::PointerEvent { kind, x, y, tol: 4.0, ..Default::default() }).unwrap();
}
#[test]
fn format_painter_applies_to_a_dragged_text_selection() {
use crate::tools::PointerKind::{Down, Drag, Up};
let (mut s, _) = painter_armed(false);
assert!(!bold_at(&mut s, (1, 0)));
s.execute("text.move", &json!({"to": "start"})).unwrap();
// Drag across the second line's text.
pointer(&mut s, Down, 68.0, 90.0);
pointer(&mut s, Drag, 110.0, 90.0);
pointer(&mut s, Up, 110.0, 90.0);
let t = s.doc().unwrap().selection.text.clone().unwrap();
assert!(t.is_range(), "the drag should select text");
assert!(s.painter.is_none(), "the painter is used up");
assert!(bold_at(&mut s, (1, 0)));
}
#[test]
fn format_painter_applies_to_a_triple_clicked_paragraph() {
use crate::tools::PointerKind::TripleClick;
let (mut s, _) = painter_armed(true);
s.execute("text.select", &json!({"anchor": [1, 2], "caret": [1, 2]})).unwrap();
pointer(&mut s, TripleClick, 70.0, 90.0);
assert!(s.painter.is_some(), "a sticky painter stays armed");
assert!(bold_at(&mut s, (1, 0)));
}
#[test]
fn format_painter_waits_for_a_range_on_a_plain_click() {
use crate::tools::PointerKind::{Down, Up};
let (mut s, _) = painter_armed(false);
pointer(&mut s, Down, 68.0, 90.0);
pointer(&mut s, Up, 68.0, 90.0);
assert!(s.painter.is_some());
}
#[test]
fn format_painter_on_part_of_a_bulleted_line_keeps_its_bullet() {
use crate::tools::PointerKind::{Down, Drag, Up};
let (mut s, _) = painter_armed(false);
s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 5]})).unwrap();
s.execute("format.bullets", &json!({"on": true})).unwrap();
let bulleted = |s: &mut Session| s.execute("format.state", &json!({})).unwrap()["bullets"] == true;
assert!(bulleted(&mut s));
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();
s.execute("format.painter", &json!({})).unwrap();
pointer(&mut s, Down, 68.0, 90.0);
pointer(&mut s, Drag, 100.0, 90.0);
pointer(&mut s, Up, 100.0, 90.0);
assert!(s.painter.is_none());
assert!(bold_at(&mut s, (1, 0)));
assert!(bulleted(&mut s), "the bullet stays");
}
/// Three bulleted paragraphs "one", "two", "three" and the painter armed from bold plain text.
fn painter_over_bullets() -> Session {
let (mut s, _) = editing("one\ntwo\nthree\nplain", (0, 0));
s.execute("text.select", &json!({"anchor": [3, 0], "caret": [3, 5]})).unwrap();
s.execute("format.bold", &json!({"on": true})).unwrap();
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [2, 5]})).unwrap();
s.execute("format.bullets", &json!({"on": true})).unwrap();
s.execute("text.select", &json!({"anchor": [3, 0], "caret": [3, 5]})).unwrap();
s.execute("format.painter", &json!({})).unwrap();
s
}
fn bulleted(s: &mut Session, para: usize) -> bool {
s.execute("text.select", &json!({"anchor": [para, 0], "caret": [para, 0]})).unwrap();
s.execute("format.state", &json!({})).unwrap()["bullets"] == true
}
#[test]
fn format_painter_paints_only_the_paragraphs_a_selection_covers() {
// Mid paragraph 0 to mid paragraph 2: only paragraph 1 is covered, so only it loses its bullet.
let mut s = painter_over_bullets();
s.execute("text.select", &json!({"anchor": [0, 1], "caret": [2, 2]})).unwrap();
s.execute("format.painterApply", &json!({})).unwrap();
assert_eq!([bulleted(&mut s, 0), bulleted(&mut s, 1), bulleted(&mut s, 2)], [true, false, true]);
// Start of paragraph 0 to mid paragraph 2: the partly covered last paragraph keeps its bullet.
let mut s = painter_over_bullets();
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [2, 2]})).unwrap();
s.execute("format.painterApply", &json!({})).unwrap();
assert_eq!([bulleted(&mut s, 0), bulleted(&mut s, 1), bulleted(&mut s, 2)], [false, false, true]);
}
#[test]
fn format_painter_triple_click_leaves_the_next_paragraph_alone() {
use crate::tools::PointerKind::TripleClick;
let mut s = painter_over_bullets();
s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap();
pointer(&mut s, TripleClick, 70.0, 70.0);
assert!(s.painter.is_none());
assert_eq!([bulleted(&mut s, 0), bulleted(&mut s, 1), bulleted(&mut s, 2)], [false, true, true]);
}
#[test]
fn format_painter_ignores_a_double_click_that_selects_nothing() {
use crate::tools::PointerKind::DoubleClick;
let mut s = painter_over_bullets();
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [1, 2]})).unwrap();
let before = sel(&s);
pointer(&mut s, DoubleClick, 900.0, 520.0);
assert_eq!(sel(&s), before);
assert!(s.painter.is_some(), "a click that left the selection alone keeps the painter");
assert!(bulleted(&mut s, 0));
}
#[test]
fn format_painter_ignores_a_shift_press_that_leaves_the_selection() {
use crate::tools::PointerKind::{Down, Up};
let (mut s, _) = painter_armed(false);
// Find the caret position under the point, then select from the line start to it.
pointer(&mut s, Down, 110.0, 90.0);
pointer(&mut s, Up, 110.0, 90.0);
let k = caret(&s);
assert!(k.1 > 0);
s.execute("text.select", &json!({"anchor": [k.0, 0], "caret": [k.0, k.1]})).unwrap();
// Shift-pressing at the caret keeps the range, so there is nothing new to paint.
let shift = Mods { shift: true, ..NONE };
for kind in [Down, Up] {
s.pointer(crate::tools::PointerEvent { kind, x: 110.0, y: 90.0, mods: shift, tol: 4.0, ..Default::default() }).unwrap();
}
assert_eq!(sel(&s), ((k.0, 0), k));
assert!(s.painter.is_some(), "the painter stays armed");
assert!(!bold_at(&mut s, (1, 0)));
}
+132 -19
View File
@@ -34,6 +34,9 @@ pub struct Mods {
pub cmd: bool,
}
/// A text selection's shape, table cell, notes flag, anchor and caret.
type TextTarget = (ShapeId, Option<(usize, usize)>, bool, (usize, usize), (usize, usize));
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PointerEvent {
@@ -191,6 +194,8 @@ enum Drag {
},
TextSelect {
shape: ShapeId,
/// The text selection before the press, to tell whether the drag changed it.
before: Option<TextTarget>,
},
/// Dragging a table border: the right edge of column `index`, or the bottom edge of row
/// `index`. `start` is the table-local press point, `size` the column's width or the row's
@@ -261,6 +266,9 @@ pub struct ToolState {
pub edit_points: Option<ShapeId>,
/// The vertex (path, index) whose control handles show in Edit Points.
pub edit_vertex: Option<(usize, usize)>,
/// The segment (path, index of the vertex ending it) last right-clicked in Edit Points, for
/// the segment menu.
pub edit_segment: Option<(usize, usize)>,
/// The point type last chosen for a vertex (path, index, type): handle drags follow it.
pub vertex_kind: Option<(usize, usize, String)>,
/// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them.
@@ -280,7 +288,12 @@ impl ToolState {
/// The shape in Edit Points, while it is still the one selected shape.
pub fn editing_points(&self, st: &crate::DocState) -> Option<ShapeId> {
self.edit_points.filter(|id| st.selection.shapes.as_slice() == [*id] && st.selection.text.is_none() && st.shape(*id).is_some())
// Only a freeform has points: after Undo takes the conversion back, Edit Points is over.
self.edit_points.filter(|id| {
st.selection.shapes.as_slice() == [*id]
&& st.selection.text.is_none()
&& st.shape(*id).is_some_and(|sh| matches!(sh.geom, deckcraft_model::Geom::Custom { .. }))
})
}
/// A freeform path is being drawn (Enter/Escape/double-click finish it).
@@ -409,10 +422,12 @@ impl Session {
}
}
}
// The frame of the text being edited: grabbing it moves the box.
// The frame of the text being edited: grabbing it moves the box, unless a shape in front
// of the text covers that spot (#931).
if let Some(e) = edited_box(st)
&& e.movable
&& edit_zone(&e.xfrm, p, tol) == EditZone::Frame
&& !shape_in_front(st, st.shapes(), e.id, p, tol, 0)
{
return Some(Hit::Frame { id: e.id });
}
@@ -467,11 +482,17 @@ impl Session {
PointerKind::Down => self.down(p, ev.mods, tol),
PointerKind::Drag => self.drag(p, ev.mods, tol),
PointerKind::Up => self.up(p, ev.mods),
PointerKind::DoubleClick => self.double_click(p, tol),
PointerKind::DoubleClick => {
let before = self.text_selection();
let r = self.double_click(p, tol)?;
self.paint_if_selection_changed(before, r)
}
PointerKind::ContextClick => self.context_click(p, tol),
PointerKind::TripleClick => {
if self.active().is_some_and(|d| d.selection.text.is_some()) {
self.execute("text.selectParagraph", &json!({}))
let before = self.text_selection();
let r = self.execute("text.selectParagraph", &json!({}))?;
self.paint_if_selection_changed(before, r)
} else {
Ok(Value::Null)
}
@@ -479,6 +500,27 @@ impl Session {
}
}
/// Format Painter applies once a text range is selected (drag, word or paragraph click).
fn paint_text_selection(&mut self) -> Result<Option<Value>> {
if self.painter.is_some() && self.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) {
return self.execute("format.painterApply", &json!({})).map(Some);
}
Ok(None)
}
/// Where the text selection is: its shape, cell or notes, anchor and caret.
fn text_selection(&self) -> Option<TextTarget> {
self.active().and_then(|d| d.selection.text.as_ref()).map(|t| (t.shape, t.cell, t.notes, t.anchor, t.caret))
}
/// A double or triple click paints only the range it selected, not an older one it left alone.
fn paint_if_selection_changed(&mut self, before: Option<TextTarget>, r: Value) -> Result<Value> {
if self.text_selection() == before {
return Ok(r);
}
self.paint_text_selection().map(|a| a.unwrap_or(r))
}
pub(crate) fn begin(&mut self, label: &str) {
if let Some(st) = self.active_mut()
&& st.interaction.is_none()
@@ -545,8 +587,9 @@ impl Session {
ToolKind::Select => {}
}
// Clicking inside the text being edited moves the caret / starts a text selection.
let before = self.text_selection();
if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? {
self.tool.drag = Some(Drag::TextSelect { shape });
self.tool.drag = Some(Drag::TextSelect { shape, before });
return Ok(json!({"caret": [pos.0, pos.1]}));
}
let hit = self.hit_test(p, tol);
@@ -654,7 +697,12 @@ impl Session {
// placeholders take the caret straight away like PowerPoint. A click in a selected
// table puts the caret in the clicked cell.
let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_)));
let enter_text = (!toggle && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p));
// A click on the frame band of a text box selects the box whole, as PowerPoint
// does, so it can be moved, duplicated or deleted; only the inside takes the caret.
let on_frame = text
&& is_text_first(st, id)
&& st.shape(id).is_some_and(|s| edit_zone(&xfrm_of(&st.doc, &st.selection, s), p, tol) == EditZone::Frame);
let enter_text = (!toggle && !on_frame && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p));
let ids2 = ids.clone();
self.select(|_, sel| {
sel.shapes = ids2;
@@ -902,7 +950,7 @@ impl Session {
};
self.tool.preview = Some(x);
}
Drag::TextSelect { shape } => {
Drag::TextSelect { shape, .. } => {
// The selection stays in the shape or table cell where it started.
let st = self.doc()?;
if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) {
@@ -1097,6 +1145,10 @@ impl Session {
let _ = cmd::text::fit_text_box(self, id);
self.refit_after_resize(id)?;
}
Some(Drag::TextSelect { before, .. }) => {
out = self.paint_if_selection_changed(before, out)?;
self.commit();
}
Some(_) => self.commit(),
None => {}
}
@@ -1199,6 +1251,12 @@ impl Session {
) => Some(*id),
None => None,
};
// Edit Points: the point or else the segment under the pointer picks the menu.
let picked = self.tool.editing_points(st).and_then(|eid| st.shape(eid)).map(|sh| match &hit {
Some(Hit::Vertex { path, index, .. }) => (Some((*path, *index)), None),
Some(Hit::Handle { .. }) => (None, None),
_ => (None, cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6).map(|(path, seg, _)| (path, seg))),
});
if !id.is_some_and(|id| st.selection.shapes.contains(&id)) {
self.select(|_, sel| {
sel.text = None;
@@ -1206,6 +1264,13 @@ impl Session {
sel.shapes = id.into_iter().collect();
})?;
}
if let Some((vertex, segment)) = picked {
// A control handle belongs to the picked point: its menu stays.
if !matches!(hit, Some(Hit::Handle { .. })) {
self.tool.edit_vertex = vertex;
}
self.tool.edit_segment = segment;
}
let st = self.doc()?;
Ok(json!({
"hit": serde_json::to_value(&hit).unwrap_or_default(),
@@ -1228,7 +1293,10 @@ impl Session {
None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside),
Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p),
};
// A shape in front of the edited text takes the click, as in PowerPoint (#931).
let in_front = shape_in_front(st, st.shapes(), t.shape, p, tol, 0);
if !inside
|| in_front
|| matches!(
self.hit_test(p, tol),
Some(
@@ -1439,6 +1507,13 @@ impl Session {
/// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to
/// apply and the guides to draw.
fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec<Guide>) {
self.snap_edges(r, exclude, tol, enabled, [true; 3], [true; 3])
}
/// `snap_rect` looking only at some of `r`'s edges: `xs_on` is left, centre, right and `ys_on`
/// top, centre, bottom. Resizing passes just the edges being dragged, so a fixed edge that
/// already sits on a line (distance 0) doesn't win and leave the dragged edge unsnapped.
fn snap_edges(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool, xs_on: [bool; 3], ys_on: [bool; 3]) -> (Vec2, Vec<Guide>) {
let Some(st) = self.active() else { return (Vec2::ZERO, vec![]) };
if !enabled || !self.prefs.smart_guides {
return (Vec2::ZERO, vec![]);
@@ -1476,7 +1551,7 @@ impl Session {
}
}
let mut best_x: Option<(f64, f64, Rect)> = None;
for edge in [r.x0, r.center().x, r.x1] {
for edge in [r.x0, r.center().x, r.x1].into_iter().zip(xs_on).filter_map(|(e, on)| on.then_some(e)) {
for (x, b) in &xs {
let d = x - edge;
if d.abs() <= tol && best_x.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) {
@@ -1485,7 +1560,7 @@ impl Session {
}
}
let mut best_y: Option<(f64, f64, Rect)> = None;
for edge in [r.y0, r.center().y, r.y1] {
for edge in [r.y0, r.center().y, r.y1].into_iter().zip(ys_on).filter_map(|(e, on)| on.then_some(e)) {
for (y, b) in &ys {
let d = y - edge;
if d.abs() <= tol && best_y.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) {
@@ -1499,32 +1574,49 @@ impl Session {
off.x = d;
let rr = r + Vec2::new(d, 0.0);
guides.push(Guide { vertical: true, pos: x, from: b.y0.min(rr.y0), to: b.y1.max(rr.y1) });
} else if self.prefs.snap_to_grid {
off.x = deckcraft_geom::snap(r.x0, self.prefs.grid_spacing) - r.x0;
} else if self.prefs.snap_to_grid && xs_on.contains(&true) {
let at = if xs_on[0] {
r.x0
} else if xs_on[2] {
r.x1
} else {
r.center().x
};
off.x = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at;
}
if let Some((d, y, b)) = best_y {
off.y = d;
let rr = r + Vec2::new(0.0, d);
guides.push(Guide { vertical: false, pos: y, from: b.x0.min(rr.x0), to: b.x1.max(rr.x1) });
} else if self.prefs.snap_to_grid {
off.y = deckcraft_geom::snap(r.y0, self.prefs.grid_spacing) - r.y0;
} else if self.prefs.snap_to_grid && ys_on.contains(&true) {
let at = if ys_on[0] {
r.y0
} else if ys_on[2] {
r.y1
} else {
r.center().y
};
off.y = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at;
}
(off, guides)
}
/// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides.
fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {
if x.rot != 0.0 {
self.tool.guides.clear();
return x;
}
let r = x.rect();
let (off, guides) = self.snap_rect(r, &[id], tol * 1.5, true);
// Only the dragged edges move, so only they look for a line to snap to. The handles sit in
// the box's unflipped frame: in a flipped box, also one that a drag past the opposite edge
// just flipped, the dragged handle is on the other side.
let (left, right) = (matches!(handle, 0 | 6 | 7), matches!(handle, 2..=4));
let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6));
let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) };
let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) };
let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
let mut nx = x;
// Only the dragged edges move.
let moves_left = matches!(handle, 0 | 6 | 7);
let moves_right = matches!(handle, 2..=4);
let moves_top = matches!(handle, 0..=2);
let moves_bottom = matches!(handle, 4..=6);
if moves_left {
nx.x += off.x;
nx.w -= off.x;
@@ -1650,6 +1742,27 @@ struct EditedBox {
movable: bool,
}
/// Whether a visible shape drawn in front of `target` is under `p`: the shapes above it in its
/// list, topmost first, and inside a group that holds it, the members above it.
fn shape_in_front(st: &crate::DocState, shapes: &[Shape], target: ShapeId, p: Point, tol: f64, depth: usize) -> bool {
if depth > 64 {
return false;
}
for s in shapes.iter().rev() {
if s.id == target {
return false;
}
if deckcraft_model::find_shape(std::slice::from_ref(s), target).is_some() {
// The group holding the text: look among its members.
return shape_in_front(st, s.children(), target, p, tol, depth + 1);
}
if !s.hidden && hit_shape(st, s, p, tol).is_some() {
return true;
}
}
false
}
fn edited_box(st: &crate::DocState) -> Option<EditedBox> {
let t = st.selection.text.as_ref()?;
if t.notes || t.cell.is_some() {
+129
View File
@@ -0,0 +1,129 @@
//! Native saves must not discard an imported chart's opaque package parts.
use std::io::{Cursor, Read};
use std::sync::Arc;
use deckcraft_engine::Session;
use deckcraft_engine::cmd::{base64_decode, base64_encode};
use deckcraft_geom::Xfrm;
use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType};
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, defaults};
use serde_json::json;
const WORKBOOK: &[u8] = b"original-synthetic-workbook\0\xff\x80";
const DRAWING: &[u8] = br#"<c:userShapes xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"/>"#;
const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
fn chart(p: &Presentation) -> &Chart {
let ShapeKind::Chart(c) = &p.slides[0].shapes[0].kind else { panic!("chart") };
c
}
fn source() -> Vec<u8> {
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id().expect("id"));
Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape {
id,
name: "Owned chart".into(),
xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)),
kind: ShapeKind::Chart(Box::new(Chart::sample(ChartType::Pie))),
..Default::default()
}];
// Start with our own chart XML, then give it two original, synthetic package parts.
let mut p = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
let ShapeKind::Chart(c) = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("chart") };
c.raw = c
.raw
.as_ref()
.map(|raw| raw.replace("</c:chartSpace>", r#"<c:externalData r:id="rWorkbook"/><c:userShapes r:id="rDrawing"/></c:chartSpace>"#));
c.kept = ChartKept {
rels: vec![
ChartRel {
id: "rWorkbook".into(),
rel_type: format!("{RT}/package"),
target: "../embeddings/owned.xlsx".into(),
part: "ppt/embeddings/owned.xlsx".into(),
external: false,
},
ChartRel {
id: "rDrawing".into(),
rel_type: format!("{RT}/chartUserShapes"),
target: "../drawings/owned.xml".into(),
part: "ppt/drawings/owned.xml".into(),
external: false,
},
],
parts: vec![
ChartPart {
name: "ppt/embeddings/owned.xlsx".into(),
content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(),
bytes: WORKBOOK.to_vec(),
native_entry: None,
},
ChartPart {
name: "ppt/drawings/owned.xml".into(),
content_type: "application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml".into(),
bytes: DRAWING.to_vec(),
native_entry: None,
},
],
};
deckcraft_pptx::export(&p).expect("PPTX fixture")
}
fn open(bytes: &[u8], name: &str) -> Session {
let mut s = Session::new();
s.execute("file.openBytes", &json!({"name":name,"data":base64_encode(bytes)})).expect("open command");
s
}
fn save(s: &mut Session, format: &str) -> Vec<u8> {
let out = s.execute("file.saveBytes", &json!({"format":format})).expect("save command");
base64_decode(out["data"].as_str().expect("data")).expect("base64")
}
#[test]
fn pptx_native_edit_save_reopen_keeps_chart_parts() {
let mut s = open(&source(), "owned.pptx");
let kept = chart(&s.doc().expect("doc").doc).kept.clone();
assert_eq!(kept.rels.len(), 2, "fixture relationships must have been imported");
assert_eq!(kept.parts.len(), 2);
s.execute("file.properties", &json!({"title":"Edited title"})).expect("edit command");
for _ in 0..2 {
s = open(&save(&mut s, "deckcraft"), "owned.deckcraft");
let p = &s.doc().expect("doc").doc;
assert_eq!(p.props.title, "Edited title");
assert_eq!(chart(p).kept, kept);
}
let bytes = save(&mut s, "pptx");
let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).expect("zip");
let mut payloads = vec![];
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let mut data = vec![];
f.read_to_end(&mut data).expect("read");
payloads.push((f.name().to_string(), data));
}
assert!(payloads.iter().any(|(n, d)| n.starts_with("ppt/embeddings/") && d == WORKBOOK));
assert!(payloads.iter().any(|(n, d)| n.starts_with("ppt/drawings/") && d == DRAWING));
let back = deckcraft_pptx::import(&bytes).expect("reopen PPTX");
assert_eq!(chart(&back).kept.rels.len(), 2);
for part in &kept.parts {
assert!(chart(&back).kept.parts.iter().any(|p| p.content_type == part.content_type && p.bytes == part.bytes));
}
}
#[test]
fn editing_chart_after_native_reopen_does_not_export_a_stale_workbook() {
let mut s = open(&source(), "owned.pptx");
s = open(&save(&mut s, "deckcraft"), "owned.deckcraft");
let doc = s.doc_mut().expect("doc");
let p = Arc::make_mut(&mut doc.doc);
let ShapeKind::Chart(c) = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("chart") };
assert_eq!(c.kept.rels.len(), 2);
c.series[0].values[0] = Some(99.0);
let bytes = save(&mut s, "pptx");
let back = deckcraft_pptx::import(&bytes).expect("PPTX");
assert_eq!(chart(&back).series[0].values[0], Some(99.0));
assert!(chart(&back).kept.parts.is_empty(), "stale source workbook must not be attached to edited chart data");
}
+2
View File
@@ -0,0 +1,2 @@
mod chart_native;
mod show_clicks;
@@ -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,
@@ -0,0 +1,262 @@
//! #844: a paragraph-by-paragraph build read from a PPTX file takes the clicks PowerPoint gives
//! it. The file lists one effect per paragraph, each with its own start (On Click or With
//! Previous); the show must not ask for more clicks, or fewer, than those effects.
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use deckcraft_anim::{ShowAction, ShowState};
use deckcraft_geom::Xfrm;
use deckcraft_model::anim::{AnimStart, Animation, TextBuild};
use deckcraft_model::text::TextBody;
use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, defaults};
use zip::write::SimpleFileOptions;
fn shape(p: &mut Presentation, name: &str, text: &str, y: f64) -> Shape {
Shape {
id: ShapeId(p.alloc_id().expect("id")),
name: name.into(),
xfrm: Some(Xfrm::new(100.0, y, 600.0, 150.0)),
text: Some(TextBody::from_text(text)),
..Default::default()
}
}
/// Two slides: "Body" (paragraphs `body`, at `levels`) and "Picture" on the first, nothing on
/// the second, saved as PPTX.
fn saved(body: &str, levels: &[u8]) -> Vec<u8> {
let mut p = defaults::new_presentation(None);
p.slides.clear();
let mut b = shape(&mut p, "Body", body, 50.0);
if let Some(t) = b.text.as_mut() {
for (para, lvl) in t.paragraphs.iter_mut().zip(levels) {
para.level = *lvl;
}
}
let pic = shape(&mut p, "Picture", "Picture", 300.0);
let anims = vec![
Animation { shape: b.id, text_build: TextBuild::ByParagraph, start: AnimStart::OnClick, duration_ms: 500, ..Default::default() },
Animation { shape: pic.id, start: AnimStart::WithPrevious, duration_ms: 500, ..Default::default() },
];
for (shapes, animations) in [(vec![b, pic], anims), (vec![], vec![])] {
let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
s.shapes = shapes;
s.animations = animations;
p.slides.push(Arc::new(s));
}
deckcraft_pptx::export(&p).expect("export")
}
fn spid(xml: &str, name: &str) -> String {
let at = xml.find(&format!(r#"name="{name}""#)).expect("shape");
let head = &xml[..at];
let id = head.rfind(r#"id=""#).expect("id") + 4;
head[id..].split('"').next().expect("spid").to_string()
}
/// One effect node as PowerPoint writes it: Fade in on `spid` (paragraph `para`).
fn effect(ids: &mut u32, spid: &str, para: Option<u32>, node: &str) -> String {
let mut id = || {
*ids += 1;
*ids
};
let tgt = match para {
Some(p) => format!(r#"<p:spTgt spid="{spid}"><p:txEl><p:pRg st="{p}" end="{p}"/></p:txEl></p:spTgt>"#),
None => format!(r#"<p:spTgt spid="{spid}"/>"#),
};
format!(
r#"<p:par><p:cTn id="{}" presetID="10" presetClass="entr" presetSubtype="0" fill="hold" grpId="0" nodeType="{node}"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst><p:set><p:cBhvr><p:cTn id="{}" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn><p:tgtEl>{tgt}</p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="visible"/></p:to></p:set><p:animEffect transition="in" filter="fade"><p:cBhvr><p:cTn id="{}" dur="500"/><p:tgtEl>{tgt}</p:tgtEl></p:cBhvr></p:animEffect></p:childTnLst></p:cTn></p:par>"#,
id(),
id(),
id()
)
}
/// A click group holding `effects`.
fn click(ids: &mut u32, effects: &[String]) -> String {
*ids += 2;
format!(
r#"<p:par><p:cTn id="{}" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst><p:par><p:cTn id="{}" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>{}</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn></p:par>"#,
*ids - 1,
*ids,
effects.concat()
)
}
/// `bytes` with the first slide's timing replaced by `clicks` (built with `effect` and `click`
/// from the spids of Body and Picture), Body's build "By paragraph".
fn with_timing(bytes: &[u8], clicks: impl Fn(&mut u32, &str, &str) -> Vec<String>) -> Vec<u8> {
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..src.len() {
let mut f = src.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = Vec::new();
f.read_to_end(&mut data).expect("read");
if name == "ppt/slides/slide1.xml" {
let xml = String::from_utf8(data).expect("utf8");
let (body, pic) = (spid(&xml, "Body"), spid(&xml, "Picture"));
let mut ids = 2;
let main = clicks(&mut ids, &body, &pic).concat();
let timing = format!(
r#"<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst><p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst>{main}</p:childTnLst></p:cTn><p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst><p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst></p:seq></p:childTnLst></p:cTn></p:par></p:tnLst><p:bldLst><p:bldP spid="{body}" grpId="0" build="p"/><p:bldP spid="{pic}" grpId="0" animBg="1"/></p:bldLst></p:timing>"#
);
let a = xml.find("<p:timing>").expect("timing");
let b = xml.find("</p:timing>").expect("timing end") + "</p:timing>".len();
data = format!("{}{timing}{}", &xml[..a], &xml[b..]).into_bytes();
}
out.start_file(name, SimpleFileOptions::default()).expect("start");
out.write_all(&data).expect("write");
}
out.finish().expect("finish").into_inner()
}
/// Clicks (Next with nothing playing) from the start of the first slide to the second one.
fn clicks_to_second_slide(pres: &Presentation) -> usize {
let mut s = ShowState::new(pres);
for n in 1..=50 {
match s.next(pres) {
ShowAction::PlayStep(_) => {
// The step clock runs out before the next click.
s.step_done(pres);
}
ShowAction::Slide(1) => return n,
a => panic!("unexpected {a:?}"),
}
}
usize::MAX
}
#[test]
fn a_paragraph_build_split_by_another_shapes_effect_takes_powerpoints_clicks() {
// As PowerPoint writes "Body: By paragraph, Picture: With Previous after the first
// paragraph": click 1 = Body ¶0 + Picture, click 2 = Body ¶1, click 3 = Body ¶2.
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
let e0 = effect(ids, body, Some(0), "clickEffect");
let ep = effect(ids, pic, None, "withEffect");
let e1 = effect(ids, body, Some(1), "clickEffect");
let e2 = effect(ids, body, Some(2), "clickEffect");
vec![click(ids, &[e0, ep]), click(ids, &[e1]), click(ids, &[e2])]
});
let pres = deckcraft_pptx::import(&bytes).expect("import");
assert_eq!(ShowState::steps(&pres, 0), 3, "three click steps, as in the file");
assert_eq!(clicks_to_second_slide(&pres), 4, "three builds and one click to move on");
}
#[test]
fn a_build_by_second_level_paragraphs_takes_a_click_per_paragraph() {
// "By 2nd level paragraphs": the heading and each sub-point have their own click.
// Picture comes in with the last one, so nothing splits Body's build.
let bytes = with_timing(&saved("Question\nFirst\nSecond", &[0, 1, 1]), |ids, body, pic| {
let e0 = effect(ids, body, Some(0), "clickEffect");
let e1 = effect(ids, body, Some(1), "clickEffect");
let e2 = effect(ids, body, Some(2), "clickEffect");
let ep = effect(ids, pic, None, "withEffect");
vec![click(ids, &[e0]), click(ids, &[e1]), click(ids, &[e2, ep])]
});
let pres = deckcraft_pptx::import(&bytes).expect("import");
assert_eq!(ShowState::steps(&pres, 0), 3, "three click steps, as in the file");
}
/// The first slide's XML in a saved PPTX.
fn slide_xml(bytes: &[u8]) -> String {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut xml = String::new();
zip.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("read");
xml
}
/// Paragraph and start of each animation on the first slide.
fn effects(pres: &Presentation) -> Vec<(Option<u32>, AnimStart, TextBuild)> {
pres.slides[0].animations.iter().map(|a| (a.paragraph, a.start, a.text_build)).collect()
}
/// "By 1st level paragraphs" as PowerPoint writes it: a sub-point comes in with its parent.
fn first_level_build() -> Vec<u8> {
with_timing(&saved("One\nSub\nTwo", &[0, 1, 0]), |ids, body, pic| {
let e0 = effect(ids, body, Some(0), "clickEffect");
let e1 = effect(ids, body, Some(1), "withEffect");
let e2 = effect(ids, body, Some(2), "clickEffect");
let ep = effect(ids, pic, None, "withEffect");
vec![click(ids, &[e0, e1]), click(ids, &[e2, ep])]
})
}
#[test]
fn a_whole_first_level_build_still_reads_as_one_by_paragraph_animation() {
let pres = deckcraft_pptx::import(&first_level_build()).expect("import");
assert_eq!(effects(&pres)[0], (None, AnimStart::OnClick, TextBuild::ByParagraph));
assert_eq!(pres.slides[0].animations.len(), 2, "Body's build and Picture");
assert_eq!(ShowState::steps(&pres, 0), 2);
}
#[test]
fn a_by_paragraph_build_with_sub_points_survives_saving() {
let pres = deckcraft_pptx::import(&first_level_build()).expect("import");
let again = deckcraft_pptx::import(&deckcraft_pptx::export(&pres).expect("export")).expect("reimport");
assert_eq!(effects(&again), effects(&pres));
assert_eq!(ShowState::steps(&again, 0), 2, "the sub-point still comes in with its parent");
}
#[test]
fn paragraph_effects_save_as_powerpoint_writes_them_and_read_back_unchanged() {
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
let e0 = effect(ids, body, Some(0), "clickEffect");
let ep = effect(ids, pic, None, "withEffect");
let e1 = effect(ids, body, Some(1), "clickEffect");
let e2 = effect(ids, body, Some(2), "clickEffect");
vec![click(ids, &[e0, ep]), click(ids, &[e1]), click(ids, &[e2])]
});
let pres = deckcraft_pptx::import(&bytes).expect("import");
let saved = deckcraft_pptx::export(&pres).expect("export");
let xml = slide_xml(&saved);
let body = spid(&xml, "Body");
let blds: Vec<&str> = xml.split("<p:bldP ").skip(1).filter(|b| b.starts_with(&format!(r#"spid="{body}""#))).collect();
// ¶0 is its own build (Picture sits between), ¶1 and ¶2 share one.
assert_eq!(blds.len(), 2, "{blds:?}");
assert!(blds.iter().all(|b| b.contains(r#"build="p""#) && !b.contains("animBg")), "{blds:?}");
let again = deckcraft_pptx::import(&saved).expect("reimport");
assert_eq!(effects(&again), effects(&pres));
assert_eq!(ShowState::steps(&again, 0), 3);
}
#[test]
fn a_by_paragraph_build_on_blank_text_saves_as_a_whole_shape_effect() {
let xml = slide_xml(&saved(" ", &[0]));
assert!(!xml.contains(r#"build="p""#), "no paragraph build without a paragraph to animate");
assert!(!xml.contains("<p:txEl>"), "the effect targets the whole shape");
}
/// Body's three paragraphs under `build="p"`, effects in the order `effects` gives them, then
/// Picture with the last one.
fn three_paragraphs(effects: impl Fn(&mut u32, &str) -> Vec<String>) -> Presentation {
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
let mut clicks: Vec<String> = effects(ids, body).iter().map(|e| click(ids, std::slice::from_ref(e))).collect();
let ep = effect(ids, pic, None, "clickEffect");
clicks.push(click(ids, &[ep]));
clicks
});
deckcraft_pptx::import(&bytes).expect("import")
}
#[test]
fn a_reversed_build_keeps_its_order() {
let pres = three_paragraphs(|ids, body| (0..3).rev().map(|p| effect(ids, body, Some(p), "clickEffect")).collect());
let paras: Vec<Option<u32>> = pres.slides[0].animations.iter().map(|a| a.paragraph).collect();
assert_eq!(paras, vec![Some(2), Some(1), Some(0), None], "last paragraph first, as in the file");
}
#[test]
fn a_paragraph_with_its_own_duration_keeps_its_own_effect() {
let pres = three_paragraphs(|ids, body| {
(0..3)
.map(|p| {
let e = effect(ids, body, Some(p), "clickEffect");
if p == 1 { e.replace(r#"dur="500""#, r#"dur="900""#) } else { e }
})
.collect()
});
let durs: Vec<(Option<u32>, u32)> = pres.slides[0].animations.iter().map(|a| (a.paragraph, a.duration_ms)).collect();
assert_eq!(durs, vec![(Some(0), 500), (Some(1), 900), (Some(2), 500), (None, 500)]);
}
+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");
+55 -29
View File
@@ -161,12 +161,12 @@ pub struct FaceRef(&'static FontFace);
impl FaceRef {
/// The handle for a loaded face (memoised per face).
pub fn of(face: &Arc<FontFace>) -> FaceRef {
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
static LEAKED: RwLock<Vec<Option<&FontFace>>> = RwLock::new(Vec::new());
let id = face.id as usize;
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(std::sync::PoisonError::into_inner).get(id) {
return FaceRef(f);
}
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
let mut w = LEAKED.write().unwrap_or_else(std::sync::PoisonError::into_inner);
if w.len() <= id {
w.resize(id + 1, None);
}
@@ -743,7 +743,7 @@ pub fn base_style(style: &str) -> &str {
}
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
/// Weight implied by a style name.
@@ -807,7 +807,7 @@ impl FontDb {
pub fn set_system_fallback(&self, on: bool) {
#[cfg(not(target_arch = "wasm32"))]
{
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled = on;
}
#[cfg(target_arch = "wasm32")]
let _ = on;
@@ -822,14 +822,14 @@ impl FontDb {
}
pub(crate) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
self.faces.read().unwrap_or_else(|e| e.into_inner())
self.faces.read().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The system font catalog, scanned first if it hasn't been yet.
#[cfg(not(target_arch = "wasm32"))]
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec<CatalogEntry>> {
self.ensure_catalog();
self.catalog.read().unwrap_or_else(|e| e.into_inner())
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated.
@@ -885,7 +885,7 @@ impl FontDb {
}
}
}
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
let mut faces = self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut added = 0;
for f in new {
// Checked again under the lock: another thread may have added the same face.
@@ -923,7 +923,7 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))]
pub fn load_system_fonts(&self) -> usize {
// Families load again from the new catalog when next looked up.
self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear();
self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
// The first scan, or a rescan once it is done (never both at once).
let mut first = None;
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
@@ -959,7 +959,7 @@ impl FontDb {
}
continue;
}
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
@@ -975,7 +975,7 @@ impl FontDb {
}
let n = found.len();
log::debug!("cataloged {n} system font faces");
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
*self.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner) = found;
n
}
@@ -983,7 +983,7 @@ impl FontDb {
/// it is loaded.
#[cfg(not(target_arch = "wasm32"))]
fn load_cataloged(&self, family: &str) -> bool {
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
self.load_family(family, &loading)
}
@@ -1047,7 +1047,7 @@ impl FontDb {
// them rather than finding none and falling back to another family.
#[cfg(not(target_arch = "wasm32"))]
{
let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner());
let mut loaded = self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let key = family.to_lowercase();
if !loaded.contains(&key) {
self.load_cataloged(family);
@@ -1112,7 +1112,7 @@ impl FontDb {
}
let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?;
let f = Arc::new(f);
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone());
self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(f.clone());
Some(f)
}
@@ -1175,36 +1175,59 @@ 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;
}
let seen = self.read_faces().len();
{
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !sys.enabled || sys.misses.contains(&c) {
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());
let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// Another thread may have loaded a face for `c` (faces are only ever added), or found
// there is none, while this one waited.
if self.read_faces().len() != seen && covered(self) {
return true;
}
if self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.contains(&c) {
if self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).misses.contains(&c) {
return false;
}
for fam in SYSTEM_FALLBACKS {
@@ -1215,7 +1238,8 @@ impl FontDb {
}
}
}
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
let mut paths: Vec<std::path::PathBuf> =
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().map(|e| e.path.clone()).collect();
paths.sort();
paths.dedup();
for p in paths {
@@ -1229,7 +1253,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(std::sync::PoisonError::into_inner).misses.insert(c);
}
false
}
@@ -1301,10 +1327,10 @@ impl FontDb {
if c.is_control() || c.is_whitespace() {
return false;
}
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled {
if !self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled {
return false;
}
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) {
return false;
}
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
@@ -1322,7 +1348,7 @@ impl FontDb {
return true;
}
}
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c);
false
}
@@ -1334,12 +1360,12 @@ impl FontDb {
return false;
}
{
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !sys.enabled {
return false;
}
}
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) {
return false;
}
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
@@ -1357,14 +1383,14 @@ impl FontDb {
return true;
}
}
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c);
false
}
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
}
let mut pen = FlipPen(BezPath::new());
@@ -1374,7 +1400,7 @@ impl FontDb {
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
}
let p = Arc::new(pen.0);
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
+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
+112
View File
@@ -0,0 +1,112 @@
//! Keep opaque chart payloads out of the JSON manifest and within the media load budget.
use std::collections::HashSet;
use std::io::Cursor;
use std::sync::Arc;
use deckcraft_model::chart::ChartPart;
use deckcraft_model::{Master, Presentation, Shape, ShapeKind};
use crate::{FormatError, MAX_ENTRY, Result, read_entry_max};
const PREFIX: &str = "chart-parts/";
type Payload = (String, String, Vec<u8>);
pub(super) fn separate(p: &mut Presentation) -> Result<Vec<Payload>> {
let mut payloads = Vec::new();
visit_parts(p, |part| {
// The native namespace is independent of the original OPC names, which may repeat
// when charts from several decks are combined. No user-supplied path is extracted.
let entry = format!("{PREFIX}{}", payloads.len());
part.native_entry = Some(entry.clone());
payloads.push((entry, part.content_type.clone(), std::mem::take(&mut part.bytes)));
Ok(())
})?;
Ok(payloads)
}
pub(super) fn restore(
p: &mut Presentation,
z: &mut zip::ZipArchive<Cursor<&[u8]>>,
left: &mut u64,
mut items: usize,
max_items: usize,
damaged: &mut Vec<String>,
) -> Result<()> {
let mut seen = HashSet::new();
visit_parts(p, |part| {
if items >= max_items {
return Err(FormatError::Damaged(format!("media and chart parts exceed {max_items} items")));
}
items += 1;
if let Some(entry) = part.native_entry.take() {
// Accept only names produced by separate(), and inflate each member once.
let canonical = entry.strip_prefix(PREFIX).and_then(|s| s.parse::<usize>().ok()).is_some_and(|n| entry == format!("{PREFIX}{n}"));
if !canonical || !seen.insert(entry.clone()) || !part.bytes.is_empty() {
return Err(FormatError::Damaged("invalid or ambiguous chart part reference".into()));
}
if z.index_for_name(&entry).is_none() {
// Missing members consume no inflation work and can be reported for recovery.
damaged.push(format!("{entry}: missing chart part"));
return Ok(());
}
if z.by_name(&entry).is_ok_and(|f| f.size() > *left) {
return Err(FormatError::Damaged("embedded media and chart parts are larger than DeckCraft can open".into()));
}
// A CRC/decode error may already have inflated bytes. Stop on that first error
// rather than letting later corrupt members reuse the unchanged byte budget.
let bytes = read_entry_max(z, &entry, (*left).min(MAX_ENTRY))?;
*left -= bytes.len() as u64;
part.bytes = bytes;
} else {
// Older manifests used JSON byte arrays. Charge those too, after the existing
// bounded manifest parser has read them, so resaving migrates them losslessly.
let size = part.bytes.len() as u64;
if size > (*left).min(MAX_ENTRY) {
return Err(FormatError::Damaged("inline chart part is larger than DeckCraft can open".into()));
}
*left -= size;
}
Ok(())
})
}
fn visit_parts(p: &mut Presentation, mut visit: impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
// Use an explicit stack for groups. Charts can also live on masters and their layouts,
// including the notes and handout masters; none of these owners may leak inline bytes.
for slide in &mut p.slides {
shapes(&mut Arc::make_mut(slide).shapes, &mut visit)?;
}
for master in &mut p.masters {
master_parts(Arc::make_mut(master), &mut visit)?;
}
for master in [&mut p.notes_master, &mut p.handout_master].into_iter().flatten() {
master_parts(Arc::make_mut(master), &mut visit)?;
}
Ok(())
}
fn master_parts(master: &mut Master, visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
shapes(&mut master.shapes, visit)?;
for layout in &mut master.layouts {
shapes(&mut layout.shapes, visit)?;
}
Ok(())
}
fn shapes(root: &mut [Shape], visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
let mut pending = vec![root];
while let Some(shapes) = pending.pop() {
for shape in shapes {
match &mut shape.kind {
ShapeKind::Group { children, .. } => pending.push(children),
ShapeKind::Chart(chart) => {
for part in &mut chart.kept.parts {
visit(part)?;
}
}
_ => {}
}
}
}
Ok(())
}
+234
View File
@@ -0,0 +1,234 @@
use super::*;
use deckcraft_model::chart::{Chart, ChartPart, ChartRel, ChartType};
use deckcraft_model::{Master, Shape, ShapeId};
use serde_json::{Value, json};
const PART: &str = "ppt/embeddings/owned.xlsx";
fn fixture(payload: Vec<u8>) -> Presentation {
// Construct through the old manifest representation: this also exercises compatibility.
let mut part: ChartPart = serde_json::from_value(json!({
"name": PART, "contentType": "application/zip", "bytes": []
}))
.unwrap();
part.bytes = payload;
let mut chart = Chart::sample(ChartType::Pie);
chart.kept.rels.push(ChartRel {
id: "rWorkbook".into(),
rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(),
target: "../embeddings/owned.xlsx".into(),
part: PART.into(),
external: false,
});
chart.kept.parts.push(part);
let mut p = Presentation::default();
let child = Shape { id: ShapeId(p.alloc_id().unwrap()), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() };
let group = Shape {
id: ShapeId(p.alloc_id().unwrap()),
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
..Default::default()
};
Arc::make_mut(&mut p.slides[0]).shapes = vec![group];
p
}
fn part_json(value: &mut Value) -> Option<&mut Value> {
if value.get("name").and_then(Value::as_str) == Some(PART) && value.get("contentType").is_some() {
return Some(value);
}
match value {
Value::Array(values) => values.iter_mut().find_map(part_json),
Value::Object(values) => values.values_mut().find_map(part_json),
_ => None,
}
}
fn rewrite_manifest(bytes: &[u8], edit: impl FnOnce(&mut Value)) -> Vec<u8> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut json: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap();
edit(&mut json);
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..z.len() {
let f = z.by_index(i).unwrap();
if f.name() == "presentation.json" {
out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(&serde_json::to_vec(&json).unwrap()).unwrap();
} else {
out.raw_copy_file(f).unwrap();
}
}
out.finish().unwrap().into_inner()
}
#[test]
fn large_chart_payload_is_a_referenced_zip_entry() {
let payload = vec![200; 3 * 1024 * 1024];
let p = fixture(payload.clone());
let bytes = save(&p).unwrap();
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
assert!(z.by_name("presentation.json").unwrap().size() < 128 * 1024, "binary chart bytes must not become JSON numbers");
let mut manifest: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap();
let part = part_json(&mut manifest).unwrap();
assert!(part.get("bytes").is_none(), "new manifests keep only metadata and a reference");
let entry = part["nativeEntry"].as_str().unwrap();
assert!(entry.starts_with("chart-parts/"));
assert_eq!(read_entry(&mut z, entry).unwrap(), payload);
assert!(read_entry_max(&mut z, entry, payload.len() as u64 - 1).is_err());
assert_eq!(load(&bytes).unwrap(), p);
}
#[test]
fn chart_parts_share_media_byte_and_item_budgets() {
let mut p = fixture(vec![7; 600]);
p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
let bytes = save(&p).unwrap();
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err(), "chart payloads share the cumulative media budget");
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err(), "chart parts share the media item budget");
let duplicated = rewrite_manifest(&bytes, |manifest| {
let media = manifest["presentation"]["media"].as_array_mut().unwrap();
media.push(media[0].clone());
});
assert!(load_within(&duplicated, Budget { total: 1200, items: 2 }).is_err(), "duplicate media IDs still count as listed items");
}
#[test]
fn legacy_inline_chart_bytes_are_budgeted_and_preserved() {
let mut p = fixture(vec![9; 600]);
p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
let legacy = rewrite_manifest(&save(&p).unwrap(), |manifest| {
let part = part_json(manifest).unwrap().as_object_mut().unwrap();
part.remove("nativeEntry");
part.insert("bytes".into(), json!(vec![9_u8; 600]));
});
assert_eq!(load_within(&legacy, Budget { total: 1200, items: 2 }).unwrap().0, p);
assert!(load_within(&legacy, Budget { total: 1199, items: 2 }).is_err());
assert!(load_within(&legacy, Budget { total: 1200, items: 1 }).is_err());
}
#[test]
fn chart_payloads_in_every_shape_owner_get_distinct_entries() {
let mut p = fixture(vec![42; 4096]);
let shapes = p.slides[0].shapes.clone();
let master = Arc::make_mut(&mut p.masters[0]);
master.shapes = shapes.clone();
master.layouts[0].shapes = shapes.clone();
p.notes_master = Some(Arc::new(Master { shapes: shapes.clone(), ..Default::default() }));
p.handout_master = Some(Arc::new(Master { shapes, ..Default::default() }));
let bytes = save(&p).unwrap();
let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
assert_eq!(z.file_names().filter(|name| name.starts_with("chart-parts/")).count(), 5);
assert_eq!(load(&bytes).unwrap(), p);
}
#[test]
fn missing_or_corrupt_chart_payload_is_reported() {
let p = fixture(b"SYNTHETIC-CHART-PAYLOAD-0123456789".to_vec());
let bytes = save(&p).unwrap();
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
let mut json: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap();
let name = part_json(&mut json).unwrap()["nativeEntry"].as_str().unwrap().to_owned();
let start = z.by_name(&name).unwrap().data_start() as usize;
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..z.len() {
let f = z.by_index(i).unwrap();
if f.name() != name {
out.raw_copy_file(f).unwrap();
}
}
let missing = out.finish().unwrap().into_inner();
let (q, damaged) = load_checked(&missing).unwrap();
assert_eq!(damaged.len(), 1, "{damaged:?}");
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
assert!(chart.kept.parts[0].bytes.is_empty());
let mut corrupt = bytes.clone();
corrupt[start] ^= 0xff;
assert!(load_checked(&corrupt).is_err());
}
#[test]
fn chart_references_cannot_alias_other_members_or_mix_inline_bytes() {
let p = fixture(vec![3; 10]);
let bytes = save(&p).unwrap();
for entry in ["media/1", "chart-parts/../0", "chart-parts/00", "chart-parts/-1"] {
let changed = rewrite_manifest(&bytes, |manifest| {
part_json(manifest).unwrap()["nativeEntry"] = json!(entry);
});
assert!(load(&changed).is_err(), "accepted {entry}");
}
let ambiguous = rewrite_manifest(&bytes, |manifest| {
part_json(manifest).unwrap()["bytes"] = json!([3]);
});
assert!(load(&ambiguous).is_err());
let mut p = p;
let ShapeKind::Group { children, .. } = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("group") };
let ShapeKind::Chart(chart) = &mut children[0].kind else { panic!("chart") };
chart.kept.parts.push(chart.kept.parts[0].clone());
let aliased = rewrite_manifest(&save(&p).unwrap(), |manifest| {
fn alias(value: &mut Value) {
if let Some(entry) = value.get_mut("nativeEntry") {
*entry = json!("chart-parts/0");
}
match value {
Value::Array(values) => values.iter_mut().for_each(alias),
Value::Object(values) => values.values_mut().for_each(alias),
_ => {}
}
}
alias(manifest);
});
assert!(load(&aliased).is_err(), "one ZIP member must not be inflated repeatedly");
}
fn with_two_parts() -> Presentation {
let mut p = fixture(vec![3; 10]);
let ShapeKind::Group { children, .. } = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("group") };
let ShapeKind::Chart(chart) = &mut children[0].kind else { panic!("chart") };
let mut second = chart.kept.parts[0].clone();
second.bytes = vec![4; 10];
chart.kept.parts.push(second);
p
}
#[test]
fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() {
let mut bytes = save(&with_two_parts()).unwrap();
let offsets: Vec<_> = {
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
(0..2).map(|i| z.by_name(&format!("chart-parts/{i}")).unwrap().data_start() as usize).collect()
};
for offset in offsets {
bytes[offset] ^= 0xff;
}
// A failed CRC may already have inflated bytes. Continuing with the same remaining
// budget would let each corrupt member consume that work budget again.
let result = load_within(&bytes, Budget { total: 10, items: 2 });
assert!(result.is_err(), "two corrupt 10-byte members must not reuse a 10-byte budget");
assert!(result.unwrap_err().to_string().contains("chart-parts/0"));
}
#[test]
fn duplicate_physical_zip_names_are_indexed_and_inflated_once() {
let mut bytes = save(&with_two_parts()).unwrap();
// ZipWriter refuses duplicate names. Forge equal-length local/central-directory names
// instead, to exercise ZipArchive's duplicate-name indexing on an untrusted archive.
let original = b"chart-parts/1";
let mut replaced = 0;
for offset in 0..=bytes.len() - original.len() {
if &bytes[offset..offset + original.len()] == original {
bytes[offset + original.len() - 1] = b'0';
replaced += 1;
}
}
assert_eq!(replaced, 2);
let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
assert_eq!(z.file_names().filter(|n| n.starts_with("chart-parts/")).count(), 1);
let (q, damaged) = load_within(&bytes, Budget { total: 10, items: 2 }).unwrap();
assert_eq!(damaged.len(), 1, "the second reference is missing, not inflated again");
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
assert_eq!(chart.kept.parts[0].bytes, vec![4; 10]);
assert!(chart.kept.parts[1].bytes.is_empty());
}
+344 -19
View File
@@ -1,16 +1,21 @@
//! The native `.deckcraft` format: a zip holding `presentation.json` (the model, minus media
//! bytes), `media/<id>` files with the embedded media, and `mimetype`. Readers ignore unknown
//! bytes), `media/<id>` and `chart-parts/<index>` binary entries, and `mimetype`. Readers ignore unknown
//! fields, so newer files open in older builds with what those builds understand.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod chart_parts;
mod finite;
pub mod rtf;
#[cfg(test)]
mod chart_parts_tests;
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.
@@ -20,9 +25,9 @@ pub const EXTENSION: &str = "deckcraft";
pub const VERSION: u32 = 1;
/// Largest entry we will inflate (guards against zip bombs).
const MAX_ENTRY: u64 = 2 << 30;
/// All embedded media together, inflated (#393).
/// All embedded media and chart sidecars together, inflated (#393).
const MAX_TOTAL_MEDIA: u64 = 4 << 30;
/// Media items one file may list.
/// Media items and chart sidecar parts one file may list.
const MAX_MEDIA_ITEMS: usize = 100_000;
/// What one load may inflate.
@@ -62,12 +67,10 @@ pub fn save(p: &Presentation) -> Result<Vec<u8>> {
let w = |e: zip::result::ZipError| FormatError::Write(e.to_string());
z.start_file("mimetype", stored).map_err(w)?;
z.write_all(MIMETYPE.as_bytes()).map_err(|e| FormatError::Write(e.to_string()))?;
let m = Manifest {
format: "deckcraft".into(),
version: VERSION,
generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")),
presentation: p.clone(),
};
let mut presentation = p.clone();
let parts = chart_parts::separate(&mut presentation)?;
let m =
Manifest { format: "deckcraft".into(), version: VERSION, generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")), presentation };
let json = serde_json::to_vec(&finite::Finite(&m)).map_err(|e| FormatError::Write(e.to_string()))?;
z.start_file("presentation.json", deflate).map_err(w)?;
z.write_all(&json).map_err(|e| FormatError::Write(e.to_string()))?;
@@ -80,6 +83,11 @@ pub fn save(p: &Presentation) -> Result<Vec<u8>> {
z.start_file(format!("media/{}", item.id.0), opts).map_err(w)?;
z.write_all(&item.data).map_err(|e| FormatError::Write(e.to_string()))?;
}
for (name, content_type, bytes) in parts {
let opts = if is_compressed(&content_type) { stored } else { deflate };
z.start_file(name, opts).map_err(w)?;
z.write_all(&bytes).map_err(|e| FormatError::Write(e.to_string()))?;
}
z.finish().map_err(w)?;
}
Ok(buf.into_inner())
@@ -119,14 +127,15 @@ fn read_entry_max(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64)
Ok(out)
}
/// Read a presentation. Embedded media that is missing or damaged is left empty; use
/// [`load_checked`] to learn which.
/// Read a presentation. Missing media/chart parts are left empty; use [`load_checked`] to
/// learn which. A damaged chart member refuses the file before further chart inflation.
pub fn load(bytes: &[u8]) -> Result<Presentation> {
load_checked(bytes).map(|(p, _)| p)
}
/// Read a presentation and list the embedded media that couldn't be read (missing, too large
/// or failing its CRC), one message each. Linked media saved without its bytes isn't damage.
/// Read a presentation and list media that couldn't be read, plus missing chart members.
/// A chart member that is present but fails decoding, CRC or its entry limit refuses the file.
/// Linked media saved without its bytes isn't damage.
pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS })
}
@@ -140,7 +149,8 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String
}
let mut p = m.presentation;
let mut damaged = vec![];
if p.media.len() > budget.items {
let items = p.media.len();
if items > budget.items {
return Err(FormatError::Damaged(format!("{} media items (at most {})", p.media.len(), budget.items)));
}
// A repeated id names the same member: keep the first item, so the member is inflated once
@@ -163,6 +173,7 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String
Err(e) => damaged.push(e.to_string()),
}
}
chart_parts::restore(&mut p, &mut z, &mut left, items, budget.items, &mut damaged)?;
repair(&mut p);
Ok((p, damaged))
}
@@ -197,6 +208,7 @@ pub fn repair(p: &mut Presentation) {
}
}
}
reid_duplicate_shapes(p);
p.slide_size = sane_slide_size(p.slide_size);
// The notes page only lays out notes, but its size divides too.
if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) {
@@ -205,6 +217,57 @@ pub fn repair(p: &mut Presentation) {
sane_numbers(p);
}
/// Give a shape that repeats an id already used on its slide, layout or master (groups included)
/// a fresh one, so edits, selection and the Selection Pane find one shape per id. The first keeps
/// its id, and with it what animations and connectors point at. An exhausted id space leaves the
/// rest as they are (#407).
fn reid_duplicate_shapes(p: &mut Presentation) {
fn has_duplicates(shapes: &[deckcraft_model::Shape]) -> bool {
let mut seen = std::collections::HashSet::new();
let mut dup = false;
deckcraft_model::walk(shapes, &mut |sh, _| dup |= !seen.insert(sh.id));
dup
}
fn reid(shapes: &mut [deckcraft_model::Shape], seen: &mut std::collections::HashSet<deckcraft_model::ShapeId>, next: &mut u32, depth: usize) {
if depth > 64 {
return;
}
for sh in shapes {
if !seen.insert(sh.id)
&& let Some(id) = (*next).max(1).checked_add(1)
{
*next = id;
sh.id = deckcraft_model::ShapeId(id);
seen.insert(sh.id);
}
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind {
reid(children, seen, next, depth + 1);
}
}
}
let mut next = p.next_id;
let mut fix = |shapes: &mut Vec<deckcraft_model::Shape>| {
if has_duplicates(shapes) {
reid(shapes, &mut std::collections::HashSet::new(), &mut next, 0);
}
};
for s in &mut p.slides {
if has_duplicates(&s.shapes) {
fix(&mut Arc::make_mut(s).shapes);
}
}
for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) {
if has_duplicates(&m.shapes) || m.layouts.iter().any(|l| has_duplicates(&l.shapes)) {
let m = Arc::make_mut(m);
fix(&mut m.shapes);
for l in &mut m.layouts {
fix(&mut l.shapes);
}
}
}
p.next_id = next;
}
/// Smallest and largest slide side, in points: PowerPoint's 1–56 in, as Design › Slide Size allows.
pub const SLIDE_SIDE: std::ops::RangeInclusive<f64> = 72.0..=4032.0;
@@ -350,6 +413,66 @@ fn sane_xfrm(x: &mut deckcraft_model::Xfrm) {
fn sane_run(p: &mut deckcraft_model::text::RunProps) {
p.size = p.size.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.clamp(1.0, 4000.0));
for s in p.shadow.iter_mut().chain(p.inner_shadow.iter_mut()) {
sane_shadow(s);
}
if let Some(g) = p.glow.as_mut() {
g.radius = effect_size(g.radius, MAX_GLOW);
}
if let Some(r) = p.reflection.as_mut() {
sane_reflection(r);
}
p.soft_edge = p.soft_edge.map(|v| effect_size(v, MAX_SOFT_EDGE));
}
/// The largest effect sizes, in points: what Format Shape and `shape.effects` allow. A file can
/// say anything, and the renderer's memory grows with these (a glow of 10^18 pt asked for 34 GB).
const MAX_GLOW: f64 = 150.0;
const MAX_SOFT_EDGE: f64 = 100.0;
const MAX_SHADOW: f64 = 200.0;
const MAX_REFLECTION: f64 = 100.0;
/// `v` within 0..=`max`; not a number is 0.
fn effect_size(v: f64, max: f64) -> f64 {
if v.is_finite() { v.clamp(0.0, max) } else { 0.0 }
}
fn sane_shadow(s: &mut deckcraft_model::style::Shadow) {
s.blur = effect_size(s.blur, MAX_SHADOW);
s.dist = effect_size(s.dist, MAX_SHADOW);
s.dir = if s.dir.is_finite() { s.dir.rem_euclid(360.0) } else { 0.0 };
// Perspective scale (1 = 100%) and skew (degrees).
for v in [&mut s.sx, &mut s.sy] {
*v = if v.is_finite() { v.clamp(-10.0, 10.0) } else { 1.0 };
}
for v in [&mut s.kx, &mut s.ky] {
*v = if v.is_finite() { v.clamp(-89.0, 89.0) } else { 0.0 };
}
}
fn sane_reflection(r: &mut deckcraft_model::style::Reflection) {
r.blur = effect_size(r.blur, MAX_REFLECTION);
r.dist = effect_size(r.dist, MAX_REFLECTION);
}
/// Run properties a list style hands down to the text that inherits it.
fn sane_list_style(ls: &mut deckcraft_model::text::ListStyle) {
for level in ls.levels.iter_mut().flatten() {
sane_run(&mut level.run);
}
}
fn sane_effects(e: &mut deckcraft_model::Effects) {
for s in e.outer_shadow.iter_mut().chain(e.inner_shadow.iter_mut()) {
sane_shadow(s);
}
if let Some(g) = e.glow.as_mut() {
g.radius = effect_size(g.radius, MAX_GLOW);
}
e.soft_edge = e.soft_edge.map(|v| effect_size(v, MAX_SOFT_EDGE));
if let Some(r) = e.reflection.as_mut() {
sane_reflection(r);
}
}
fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
@@ -363,7 +486,11 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
if let Some(l) = sh.line.as_mut() {
l.width = l.width.filter(|v| v.is_finite() && *v >= 0.0).map(|v| v.min(1584.0));
}
if let Some(e) = sh.effects.as_mut() {
sane_effects(e);
}
if let Some(t) = sh.text.as_mut() {
sane_list_style(&mut t.list_style);
for para in &mut t.paragraphs {
sane_run(&mut para.end_props);
for r in &mut para.runs {
@@ -372,8 +499,9 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
}
}
match &mut sh.kind {
deckcraft_model::ShapeKind::Picture { fill } => {
for c in &mut fill.crop {
deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop, .. } }
| deckcraft_model::ShapeKind::Media(deckcraft_model::MediaClip { crop, .. }) => {
for c in crop {
*c = if c.is_finite() { c.clamp(-10.0, 1.0) } else { 0.0 };
}
}
@@ -381,6 +509,17 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
sane_xfrm(child);
sane_shapes(children, depth + 1);
}
deckcraft_model::ShapeKind::Table(t) => {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
sane_list_style(&mut cell.text.list_style);
}
for para in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()).flat_map(|c| c.text.paragraphs.iter_mut()) {
sane_run(&mut para.end_props);
for r in &mut para.runs {
sane_run(&mut r.props);
}
}
}
_ => {}
}
}
@@ -395,12 +534,21 @@ pub fn sane_numbers(p: &mut Presentation) {
*slide = Arc::new(fixed);
}
}
// What text and shapes inherit: the deck's default text style, each master's title, body and
// other styles, and the theme's effect styles that shape styles point at.
sane_list_style(&mut p.default_text_style);
for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) {
let mut fixed = (**m).clone();
sane_shapes(&mut fixed.shapes, 0);
for l in &mut fixed.layouts {
sane_shapes(&mut l.shapes, 0);
}
for ls in [&mut fixed.title_style, &mut fixed.body_style, &mut fixed.other_style] {
sane_list_style(ls);
}
for e in &mut fixed.theme.format.effects {
sane_effects(e);
}
if fixed != **m {
*m = Arc::new(fixed);
}
@@ -418,7 +566,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() {
@@ -487,7 +640,8 @@ pub fn markdown_to_slides(p: &mut Presentation, text: &str) -> usize {
pub fn slides_to_outline(p: &Presentation) -> String {
let mut out = String::new();
for s in &p.slides {
out.push_str(&s.title());
// One line per title: a newline would start another slide on re-import.
out.push_str(&s.title().replace('\n', " "));
out.push('\n');
for sh in &s.shapes {
if sh.ph_type().is_some_and(|k| k.is_title()) {
@@ -505,6 +659,14 @@ 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('\t');
out.push_str(&cells.join("\t"));
out.push('\n');
}
}
}
}
out
@@ -514,6 +676,54 @@ pub fn slides_to_outline(p: &Presentation) -> String {
mod tests {
use super::*;
#[test]
fn chart_sidecars_are_optional_for_older_native_files() {
use deckcraft_model::chart::{Chart, ChartType};
let chart = Chart::sample(ChartType::Pie);
let mut json = serde_json::to_value(&chart).expect("chart JSON");
assert!(json.get("kept").is_none(), "empty sidecars should not enlarge ordinary files");
assert_eq!(serde_json::from_value::<Chart>(json.clone()).expect("old chart without kept"), chart);
json["kept"] = serde_json::json!({});
assert_eq!(serde_json::from_value::<Chart>(json).expect("empty sidecar"), chart);
}
#[test]
fn native_save_keeps_chart_relationships_and_payloads() {
use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType};
use deckcraft_model::{Shape, ShapeId, ShapeKind};
let kept = ChartKept {
rels: vec![ChartRel {
id: "rWorkbook".into(),
rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(),
target: "../embeddings/data.xlsx".into(),
part: "ppt/embeddings/data.xlsx".into(),
external: false,
}],
parts: vec![ChartPart {
name: "ppt/embeddings/data.xlsx".into(),
content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(),
bytes: vec![0, 255, 128, 42],
native_entry: None,
}],
};
let mut p = defaults::new_presentation(None);
let chart = Chart { kept: kept.clone(), ..Chart::sample(ChartType::Pie) };
let s = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() };
// Serialization must reach charts inside groups as well as top-level charts.
Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape {
id: ShapeId(p.alloc_id().expect("group id")),
kind: ShapeKind::Group { children: vec![s], child: Default::default() },
..Default::default()
}];
let mut q = load(&save(&p).expect("save")).expect("load");
for _ in 0..2 {
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
assert_eq!(chart.kept, kept);
q = load(&save(&q).expect("save again")).expect("load again");
}
}
/// The same package without the entries `drop` names.
fn without(bytes: &[u8], drop: &str) -> Vec<u8> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
@@ -620,6 +830,18 @@ mod tests {
assert!(load(&b).is_err());
}
/// A video's crop is brought into the same range as a picture's (#972).
#[test]
fn a_video_crop_is_repaired_like_a_picture_crop() {
use deckcraft_model::{MediaClip, Shape};
let mut p = Presentation::default();
let clip = MediaClip { video: true, crop: [f64::NAN, 0.25, -1e9, 0.0], ..Default::default() };
Arc::make_mut(&mut p.slides[0]).shapes.push(Shape { kind: ShapeKind::Media(clip), ..Default::default() });
let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}"));
let ShapeKind::Media(m) = &back.slides[0].shapes.last().unwrap().kind else { panic!("media") };
assert_eq!(m.crop, [0.0, 0.25, -10.0, 0.0]);
}
/// Non-finite numbers are written as `null` by serde_json; the file still opens, with each
/// such number repaired (it failed as "invalid type: null, expected f64").
#[test]
@@ -690,6 +912,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 +1033,27 @@ 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 outline_export_keeps_multi_paragraph_title_on_one_line() {
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
assert_eq!(outline_to_slides(&mut p, "Intro\n\tPoint one\nSecond\n\tMore"), 2);
let title = Arc::make_mut(&mut p.slides[0]).shapes.iter_mut().find(|s| s.ph_type().is_some_and(|k| k.is_title())).expect("title placeholder");
title.text = Some(TextBody::from_text("Line one\nLine two"));
let o = slides_to_outline(&p);
assert_eq!(o, "Line one Line two\n\tPoint one\nSecond\n\tMore\n");
let mut q = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
assert_eq!(outline_to_slides(&mut q, &o), 2);
}
#[test]
fn markdown_headings_and_list_items() {
assert_eq!(md_heading("# Intro"), Some("Intro"));
@@ -806,4 +1072,63 @@ mod tests {
assert_eq!(md_list_item(not), None, "{not:?}");
}
}
/// #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}");
}
}
#[test]
fn outline_table_rows_are_indented_and_do_not_become_slides() {
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");
for sh in s.shapes.iter_mut().filter(|sh| sh.ph_type().is_some_and(|k| k.is_title())) {
sh.text = Some(TextBody::from_text("Slide title"));
}
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
for texts in [["A1", "B1"], ["A2", "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);
let mut q = defaults::new_presentation(None);
q.slides.clear();
assert_eq!(outline_to_slides(&mut q, &out), 1, "table rows became slides: {out:?}");
assert_eq!(q.slides.len(), 1, "{out:?}");
let body = q.slides[0].shapes.iter().filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join("\n");
assert!(body.contains("A1\tB1"), "{body:?}");
assert!(body.contains("A2\tB2"), "{body:?}");
}
+363
View File
@@ -0,0 +1,363 @@
//! Outline / RTF: PowerPoint's "Outline/RTF" save type, and reading such a file back as slides.
//!
//! The export writes one paragraph per slide title (outline level 0, bold, larger) and one per
//! body paragraph (outline level = indent + 1, indented), keeping bold, italic and underline.
//! The import reads paragraphs and their `\outlinelevel` (or, failing that, `\li` indent) into
//! the tab-indented outline [`crate::outline_to_slides`] understands.
use deckcraft_model::Presentation;
use deckcraft_model::text::{Paragraph, RunKind};
/// RTF-escape `s` (non-ASCII as `\uN?`, UTF-16, signed as RTF wants).
fn escape(out: &mut String, s: &str) {
for c in s.chars() {
match c {
'\\' | '{' | '}' => {
out.push('\\');
out.push(c);
}
'\t' => out.push_str("\\tab "),
'\u{b}' | '\n' => out.push_str("\\line "),
c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
c if c.is_ascii() => {}
c => {
let mut buf = [0u16; 2];
for u in c.encode_utf16(&mut buf) {
out.push_str(&format!("\\u{}?", *u as i16));
}
}
}
}
}
fn runs(out: &mut String, p: &Paragraph) {
for r in &p.runs {
if matches!(r.kind, RunKind::Break) {
out.push_str("\\line ");
continue;
}
let (b, i, u) = (r.props.bold == Some(true), r.props.italic == Some(true), r.props.underline.as_deref().is_some_and(|u| u != "none"));
if !(b || i || u) {
escape(out, &r.text);
continue;
}
out.push('{');
out.push_str(if b { "\\b" } else { "" });
out.push_str(if i { "\\i" } else { "" });
out.push_str(if u { "\\ul" } else { "" });
out.push(' ');
escape(out, &r.text);
out.push('}');
}
}
/// The presentation's outline (slide titles and body text) as RTF.
pub fn slides_to_rtf(p: &Presentation) -> String {
let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\uc1{\\fonttbl{\\f0\\fswiss Liberation Sans;}}\n");
for s in &p.slides {
let title = s.shapes.iter().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_ref());
out.push_str("\\pard\\outlinelevel0\\sb240\\b\\fs32 ");
match title.and_then(|t| t.paragraphs.iter().find(|p| !p.is_empty())) {
Some(tp) => runs(&mut out, tp),
None => escape(&mut out, &s.title()),
}
out.push_str("\\b0\\par\n");
for sh in &s.shapes {
if sh.ph_type().is_some_and(|k| k.is_title()) {
continue;
}
let Some(t) = &sh.text else { continue };
for para in t.paragraphs.iter().filter(|p| !p.is_empty()) {
let lvl = para.level.min(8) as u32;
out.push_str(&format!("\\pard\\outlinelevel{}\\li{}\\fs24 ", lvl + 1, 360 * (lvl + 1)));
runs(&mut out, para);
out.push_str("\\par\n");
}
}
}
out.push_str("}\n");
out
}
/// Whether `bytes` look like RTF.
pub fn sniff_rtf(bytes: &[u8]) -> bool {
bytes.trim_ascii_start().starts_with(b"{\\rtf")
}
/// Destinations whose text is not document text.
const SKIP: &[&str] = &[
"fonttbl",
"colortbl",
"stylesheet",
"info",
"pict",
"header",
"footer",
"footnote",
"listtable",
"listoverridetable",
"generator",
"xmlnstbl",
"themedata",
"datastore",
"latentstyles",
"rsidtbl",
"object",
"fldinst",
];
/// `i + n`, or one character on when that would land inside a multi-byte character (a fallback
/// run after `\uN` or `\'hh` may be followed by non-ASCII text).
fn advance(rtf: &str, i: usize, n: usize) -> usize {
if rtf.is_char_boundary(i + n) { i + n } else { i + rtf.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8) }
}
/// Read an RTF outline into tab-indented outline text: a paragraph's outline level (or its left
/// indent in 0.25" steps) becomes its number of tabs.
pub fn rtf_to_outline(rtf: &str) -> String {
#[derive(Clone, Copy)]
struct St {
skip: bool,
uc: usize,
}
let b = rtf.as_bytes();
let mut stack: Vec<St> = vec![];
let mut st = St { skip: false, uc: 1 };
let (mut level, mut indent): (Option<u32>, i64) = (None, 0);
let mut line = String::new();
let mut out = String::new();
let mut pending_skip = 0usize;
let mut i = 0;
let flush = |line: &mut String, level: Option<u32>, indent: i64, out: &mut String| {
let text = line.trim();
if !text.is_empty() {
let n = level.map(|l| l as i64).unwrap_or(indent.saturating_add(180) / 360).clamp(0, 9) as usize;
out.push_str(&"\t".repeat(n));
out.push_str(text);
out.push('\n');
}
line.clear();
};
while i < b.len() {
let c = b[i];
match c {
b'{' => {
stack.push(st);
i += 1;
// `{\*\dest …}`: an ignorable destination.
if b[i..].starts_with(b"\\*") {
st.skip = true;
}
}
b'}' => {
st = stack.pop().unwrap_or(St { skip: false, uc: 1 });
i += 1;
}
b'\\' => {
i += 1;
let Some(&n) = b.get(i) else { break };
if n.is_ascii_alphabetic() {
let s = i;
while i < b.len() && b[i].is_ascii_alphabetic() {
i += 1;
}
let word = &rtf[s..i];
let ns = i;
if i < b.len() && (b[i] == b'-' || b[i].is_ascii_digit()) {
i += 1;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
}
let arg: Option<i64> = rtf[ns..i].parse().ok();
if i < b.len() && b[i] == b' ' {
i += 1;
}
if pending_skip > 0 {
pending_skip -= 1;
continue;
}
if SKIP.contains(&word) {
st.skip = true;
}
if st.skip {
continue;
}
match word {
"par" => {
flush(&mut line, level, indent, &mut out);
}
"pard" => {
level = None;
indent = 0;
}
"outlinelevel" => level = arg.map(|a| a.clamp(0, 9) as u32),
"li" => indent = arg.unwrap_or(0),
"tab" | "line" => line.push(' '),
"uc" => st.uc = arg.unwrap_or(1).clamp(0, 8) as usize,
"u" => {
let v = arg.unwrap_or(0);
// RTF writes UTF-16 units as signed 16-bit values.
push_utf16(&mut line, (v & 0xffff) as u32);
pending_skip = 0;
// Skip the fallback characters.
let mut k = st.uc;
while k > 0 && i < b.len() {
if b[i] == b'\\' && b.get(i + 1) == Some(&b'\'') {
i = advance(rtf, i, 4);
} else if b[i] == b'{' || b[i] == b'}' || b[i] == b'\\' {
break;
} else {
i = advance(rtf, i, 1);
}
k -= 1;
}
}
"emdash" => line.push('—'),
"endash" => line.push('–'),
"bullet" => line.push('•'),
"lquote" => line.push('‘'),
"rquote" => line.push('’'),
"ldblquote" => line.push('“'),
"rdblquote" => line.push('”'),
_ => {}
}
} else if n == b'\'' {
let hex = rtf.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok());
i = advance(rtf, i, 3);
if !st.skip
&& let Some(h) = hex
&& h != 0
{
line.push(cp1252(h));
}
} else {
i += 1;
if st.skip {
continue;
}
match n {
b'\\' | b'{' | b'}' => line.push(n as char),
b'~' => line.push('\u{a0}'),
b'\n' | b'\r' => flush(&mut line, level, indent, &mut out),
_ => {}
}
}
}
b'\r' | b'\n' => i += 1,
_ => {
let s = i;
while i < b.len() && !matches!(b[i], b'{' | b'}' | b'\\' | b'\r' | b'\n') {
i += 1;
}
if !st.skip
&& let Some(t) = rtf.get(s..i)
{
line.push_str(&t.replace('\u{0}', ""));
}
}
}
}
flush(&mut line, level, indent, &mut out);
finish_surrogates(out)
}
/// Push a UTF-16 unit; surrogate halves are parked as private markers (NUL plus four hex digits)
/// and joined afterwards. NUL itself is the marker, so a NUL unit is dropped.
fn push_utf16(line: &mut String, v: u32) {
if v == 0 {
return;
}
match char::from_u32(v) {
Some(c) => line.push(c),
None => line.push_str(&format!("\u{0}{v:04x}")),
}
}
fn finish_surrogates(s: String) -> String {
if !s.contains('\u{0}') {
return s;
}
let mut out = String::new();
let mut rest = s.as_str();
while let Some(p) = rest.find('\u{0}') {
out.push_str(&rest[..p]);
let hi = rest.get(p + 1..p + 5).and_then(|h| u32::from_str_radix(h, 16).ok());
let lo = rest.get(p + 6..p + 10).filter(|_| rest.get(p + 5..p + 6) == Some("\u{0}")).and_then(|h| u32::from_str_radix(h, 16).ok());
match (hi, lo) {
(Some(h), Some(l)) if (0xD800..0xDC00).contains(&h) && (0xDC00..0xE000).contains(&l) => {
out.extend(char::from_u32(0x10000 + ((h - 0xD800) << 10) + (l - 0xDC00)));
rest = &rest[p + 10..];
}
_ => {
out.push('\u{fffd}');
// Skip the NUL and, when four hex digits follow, the digits; otherwise
// resume right after the NUL so no following character is lost.
let skip = if hi.is_some() { p + 5 } else { p + 1 };
rest = rest.get(skip..).unwrap_or("");
}
}
}
out.push_str(rest);
out
}
fn cp1252(b: u8) -> char {
const HIGH: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
'˜', '™', 'š', '›', 'œ', '\u{9d}', 'ž', 'Ÿ',
];
match b {
0x80..=0x9f => HIGH[(b - 0x80) as usize],
_ => b as char,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
/// Non-ASCII text after a `\\uN` or `\\'hh` escape used to split a character and panic.
#[test]
fn non_ascii_text_after_escapes_does_not_panic() {
let out = rtf_to_outline(r"{\rtf1\pard \u233é x \'€y\par}");
assert!(out.contains('x'), "{out:?}");
for rtf in [r"{\rtf1 \u233é€ z\par}", r"{\rtf1 \'é\par}", r"{\rtf1 \'4", r"{\rtf1 \u233"] {
let _ = rtf_to_outline(rtf);
}
}
/// NUL is the surrogate marker, so NULs from `\u0`, `\'00` or raw bytes must not reach `finish_surrogates`.
#[test]
fn nul_escapes_do_not_drop_text_or_add_a_slide() {
assert_eq!(rtf_to_outline(r"{\rtf1 \u0?abcé\par}"), "abcé\n");
assert_eq!(rtf_to_outline(r"{\rtf1 a\'00bé\par}"), "abé\n");
assert_eq!(rtf_to_outline("{\\rtf1 a\0b\\par}\0\0"), "ab\n");
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
}
/// A hostile `\li` near `i64::MAX` overflowed the rounding addend in debug builds.
#[test]
fn huge_left_indent_gives_the_deepest_level() {
let out = rtf_to_outline(r"{\rtf1\pard\li9223372036854775807 deep\par\pard\li-9223372036854775808 flat\par}");
assert_eq!(out, format!("{}deep\nflat\n", "\t".repeat(9)));
}
#[test]
fn export_reads_back_as_the_same_outline() {
let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false);
let outline = "Plan {Q3}\n\tGrow — fast 😀\n\t\tEurope\\Asia\nCafé résumé\n\tDone\n";
crate::outline_to_slides(&mut p, outline);
let rtf = slides_to_rtf(&p);
assert!(sniff_rtf(rtf.as_bytes()));
assert!(rtf.contains("\\outlinelevel2") && rtf.contains("Plan \\{Q3\\}"), "{rtf}");
assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p));
}
#[test]
fn reads_word_style_rtf_by_indent_and_hex_escapes() {
let rtf = r"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\generator X;}\pard\b Title one\b0\par\pard\li720 Point \'93a\'94\par\pard\li1440 Sub\par}";
assert_eq!(rtf_to_outline(rtf), "Title one\n\t\tPoint “a”\n\t\t\t\tSub\n");
}
}
+163 -6
View File
@@ -33,7 +33,8 @@ pub struct SubPath {
}
/// A draggable adjust handle (the yellow dot). `pos = origin + dir * value`; dragging projects
/// the pointer onto `dir`.
/// the pointer onto `dir`. An `angle` handle instead measures the pointer's angle about `origin`
/// (1/60 000 degree, y down, 0° at +x).
#[derive(Clone, Debug, PartialEq)]
pub struct Handle {
pub index: usize,
@@ -42,14 +43,29 @@ pub struct Handle {
pub dir: Vec2,
pub min: f64,
pub max: f64,
pub angle: bool,
}
impl Handle {
fn new(index: usize, origin: Point, dir: Vec2, value: f64, min: f64, max: f64) -> Self {
Handle { index, pos: origin + dir * value, origin, dir, min, max }
Handle { index, pos: origin + dir * value, origin, dir, min, max, angle: false }
}
/// An angle handle on the `rx`×`ry` ellipse around `center` at visual angle `value`.
fn angle(index: usize, center: Point, rx: f64, ry: f64, value: f64) -> Self {
let t = vis(value / 60000.0, rx, ry).to_radians();
let pos = Point::new(center.x + rx * t.cos(), center.y + ry * t.sin());
Handle { index, pos, origin: center, dir: Vec2::ZERO, min: 0.0, max: 21600000.0, angle: true }
}
/// The adjust value for a pointer at `p` (shape-local space).
pub fn value_at(&self, p: Point) -> f64 {
if self.angle {
let d = p - self.origin;
if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 {
return self.min;
}
let v = (d.y.atan2(d.x).to_degrees().rem_euclid(360.0) * 60000.0).round();
return if v >= self.max { self.min } else { v };
}
let l2 = self.dir.hypot2();
if l2 < 1e-18 {
return self.min;
@@ -1082,7 +1098,15 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
b.m(p0.x, p0.y).arc(rx, ry, s0, sweep);
let pi = Point::new(w / 2.0 + irx * si.to_radians().cos(), h / 2.0 + iry * si.to_radians().sin());
b.l(pi.x, pi.y).arc(irx, iry, si, isweep).z();
simple(b.take(), full, w, h)
let mut g = simple(b.take(), full, w, h);
let c = Point::new(rx, ry);
g.handles.push(Handle::angle(0, c, rx - t / 2.0, ry - t / 2.0, a(0)));
// Thickness: slides inward from the outer edge along the end angle's ray.
let pe = Point::new(rx + rx * e0.to_radians().cos(), ry + ry * e0.to_radians().sin());
let u = (c - pe).normalize();
let u = if u.x.is_finite() && u.y.is_finite() { u } else { Vec2::ZERO };
g.handles.push(Handle::new(2, pe, u * (ss / 100000.0), a(2).clamp(0.0, 50000.0), 0.0, 50000.0));
g
}
"pie" | "chord" | "arc" => {
let (rx, ry) = (w / 2.0, h / 2.0);
@@ -1095,7 +1119,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
let p0 = Point::new(rx + rx * s.to_radians().cos(), ry + ry * s.to_radians().sin());
b.m(p0.x, p0.y).arc(rx, ry, s, sw);
let text = Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355);
match name {
let mut g = match name {
"pie" => {
b.l(rx, ry).z();
simple(b.take(), text, w, h)
@@ -1111,7 +1135,11 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
f.close_path();
Geometry { paths: vec![fill_only(f), stroke_only(open)], text_rect: text, handles: vec![], sites: mid_sites(w, h) }
}
}
};
let c = Point::new(rx, ry);
g.handles.push(Handle::angle(0, c, rx, ry, a(0)));
g.handles.push(Handle::angle(1, c, rx, ry, a(1)));
g
}
"teardrop" => {
let k = a(0).clamp(0.0, 200000.0) / 100000.0;
@@ -1821,7 +1849,7 @@ fn ellipse_sites(w: f64, h: f64) -> Vec<Point> {
fn tip_handle(w: f64, h: f64, tip: Point) -> Handle {
// A free 2-D handle is represented as index 0 with zero direction; callers move both values.
Handle { index: 0, pos: tip, origin: Point::new(w / 2.0, h / 2.0), dir: Vec2::new(w / 1e5, h / 1e5), min: -1e6, max: 1e6 }
Handle { index: 0, pos: tip, origin: Point::new(w / 2.0, h / 2.0), dir: Vec2::new(w / 1e5, h / 1e5), min: -1e6, max: 1e6, angle: false }
}
fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry {
@@ -2252,6 +2280,135 @@ mod tests {
assert_eq!(h.value_at(Point::new(500.0, 0.0)), 50000.0);
}
#[test]
fn partial_circle_angle_handles_map_back() {
// Start 0° sits at the right of the ellipse, end 270° at its top.
for name in ["pie", "chord", "arc"] {
let g = build(name, 200.0, 100.0, &[0.0, 16200000.0]).unwrap();
assert_eq!(g.handles.iter().map(|h| h.index).collect::<Vec<_>>(), vec![0, 1], "{name}");
let (s, e) = (&g.handles[0], &g.handles[1]);
assert!((s.pos - Point::new(200.0, 50.0)).hypot() < 1e-6, "{name} {:?}", s.pos);
assert!((e.pos - Point::new(100.0, 0.0)).hypot() < 1e-6, "{name} {:?}", e.pos);
assert_eq!(s.value_at(s.pos), 0.0, "{name}");
assert_eq!(e.value_at(e.pos), 16200000.0, "{name}");
// Dragging to the bottom of the ellipse gives 90°.
assert_eq!(s.value_at(Point::new(100.0, 100.0)), 5400000.0, "{name}");
// Off-axis angles on a non-square ellipse: the visual 45° ray meets the 200×100
// ellipse at offset (√2000, √2000) from the centre.
let k = 2000f64.sqrt();
let g = build(name, 200.0, 100.0, &[2700000.0, 8100000.0]).unwrap();
let (s, e) = (&g.handles[0], &g.handles[1]);
assert!((s.pos - Point::new(100.0 + k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", s.pos);
assert!((e.pos - Point::new(100.0 - k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", e.pos);
for (deg, h) in [(45.0, s), (135.0, e)] {
let on = ((h.pos.x - 100.0) / 100.0).powi(2) + ((h.pos.y - 50.0) / 50.0).powi(2);
assert!((on - 1.0).abs() < 1e-9, "{name} {deg}° off ellipse: {:?}", h.pos);
assert!((h.value_at(h.pos) - deg * 60000.0).abs() <= 1.0, "{name} {deg}°: {}", h.value_at(h.pos));
}
}
for deg in [45.0, 135.0, 200.0, 315.0] {
let g = build("blockArc", 200.0, 100.0, &[deg * 60000.0, 0.0, 25000.0]).unwrap();
let start = g.handles.iter().find(|h| h.index == 0).unwrap();
// Thickness 25 → the handle runs on the 87.5×37.5 mid-ring ellipse.
let on = ((start.pos.x - 100.0) / 87.5).powi(2) + ((start.pos.y - 50.0) / 37.5).powi(2);
assert!((on - 1.0).abs() < 1e-9, "blockArc {deg}° off ring: {:?}", start.pos);
let d = start.pos - Point::new(100.0, 50.0);
assert!((d.y.atan2(d.x).to_degrees().rem_euclid(360.0) - deg).abs() < 1e-6, "blockArc {deg}°: {:?}", start.pos);
assert!((start.value_at(start.pos) - deg * 60000.0).abs() <= 1.0, "blockArc {deg}°");
}
let g = build("blockArc", 200.0, 100.0, &[10800000.0, 0.0, 25000.0]).unwrap();
let start = g.handles.iter().find(|h| h.index == 0).unwrap();
assert_eq!(start.value_at(start.pos), 10800000.0);
let thick = g.handles.iter().find(|h| h.index == 2).unwrap();
assert!((thick.value_at(thick.pos) - 25000.0).abs() <= 1.0);
}
#[test]
fn presets_with_adjust_values_expose_handles() {
// Presets whose adjust values have no handle of their own yet.
const NO_HANDLES: &[&str] = &[
"curvedConnector3",
"snip2SameRect",
"snip2DiagRect",
"snipRoundRect",
"round1Rect",
"round2SameRect",
"round2DiagRect",
"teardrop",
"halfFrame",
"corner",
"diagStripe",
"foldedCorner",
"smileyFace",
"moon",
"gear6",
"gear9",
"bracketPair",
"bracePair",
"leftBracket",
"rightBracket",
"leftBrace",
"rightBrace",
"leftRightArrow",
"upDownArrow",
"quadArrow",
"leftRightUpArrow",
"bentArrow",
"uturnArrow",
"curvedRightArrow",
"curvedLeftArrow",
"curvedUpArrow",
"curvedDownArrow",
"leftUpArrow",
"bentUpArrow",
"circularArrow",
"leftCircularArrow",
"leftRightCircularArrow",
"swooshArrow",
"rightArrowCallout",
"downArrowCallout",
"leftArrowCallout",
"upArrowCallout",
"leftRightArrowCallout",
"upDownArrowCallout",
"quadArrowCallout",
"mathPlus",
"mathMinus",
"mathMultiply",
"mathDivide",
"mathEqual",
"mathNotEqual",
"ribbon2",
"ribbon",
"ellipseRibbon2",
"ellipseRibbon",
"leftRightRibbon",
"verticalScroll",
"horizontalScroll",
"wave",
"doubleWave",
"borderCallout1",
"borderCallout2",
"borderCallout3",
"accentCallout1",
"accentCallout2",
"accentCallout3",
"callout1",
"callout2",
"callout3",
"accentBorderCallout1",
"accentBorderCallout2",
"accentBorderCallout3",
];
for p in CATALOG {
if p.defaults.is_empty() || NO_HANDLES.contains(&p.name) {
continue;
}
let g = build(p.name, 200.0, 100.0, &[]).unwrap();
assert!(!g.handles.is_empty(), "{} has adjust values but no handles", p.name);
}
}
#[test]
fn rect_area_and_ellipse_area() {
let g = build("rect", 100.0, 50.0, &[]).unwrap();
+1 -1
View File
@@ -3,7 +3,7 @@
use crate::cabac::Cabac;
use crate::error::{Result, ensure, invalid};
use crate::mbtypes::*;
use crate::mbtypes::{MbTypeInfo, Part, SubMbInfo, b_mb_type, b_sub_mb_type, intra_mb_type, p_mb_type, p_sub_mb_type};
use crate::picture::MbKind;
use crate::slice::SliceType;
use crate::slicedec::{MbCur, SliceDecoder};
+1 -1
View File
@@ -1,6 +1,6 @@
//! CAVLC residual block parsing (9.2).
use crate::cavlc_tables::*;
use crate::cavlc_tables::{COEFF_TOKEN, RUN_BEFORE, TOTAL_ZEROS_2X2, TOTAL_ZEROS_2X4, TOTAL_ZEROS_4X4};
use crate::error::{Result, ensure};
use deckcraft_bitstream::BitReader;
use std::sync::OnceLock;
+3 -3
View File
@@ -232,7 +232,7 @@ fn filter_vertical<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y: u
let mut w = [0u64; N];
for (i, w) in w.iter_mut().enumerate() {
let o = (y + i) * stride + x - 4;
let Some(&b) = pix.get(o..).and_then(|p| p.first_chunk::<8>()) else {
let Some(&b) = pix.get(o..).and_then(<[_]>::first_chunk::<8>) else {
return;
};
*w = u64::from_le_bytes(b);
@@ -259,7 +259,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
let base = (y - 4) * stride + x;
let mut s = [[0i16; N]; 8];
for (k, row) in s.iter_mut().enumerate() {
let Some(src) = pix.get(base + k * stride..).and_then(|p| p.first_chunk::<N>()) else {
let Some(src) = pix.get(base + k * stride..).and_then(<[_]>::first_chunk::<N>) else {
return;
};
for i in 0..N {
@@ -269,7 +269,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
filter_lanes(&mut s, ep, chroma);
let (k0, k1) = if chroma { (3, 5) } else { (1, 7) };
for (k, row) in s.iter().enumerate().take(k1).skip(k0) {
let Some(dst) = pix.get_mut(base + k * stride..).and_then(|p| p.first_chunk_mut::<N>()) else {
let Some(dst) = pix.get_mut(base + k * stride..).and_then(<[_]>::first_chunk_mut::<N>) else {
return;
};
for i in 0..N {
+1 -1
View File
@@ -1,7 +1,7 @@
//! Sequence and picture parameter sets (7.3.2.1, 7.3.2.2) including VUI and scaling lists.
use crate::error::{Result, ensure, unsupported};
use crate::tables::*;
use crate::tables::{DEFAULT_4X4_INTER, DEFAULT_4X4_INTRA, DEFAULT_8X8_INTER, DEFAULT_8X8_INTRA, ZIGZAG4, ZIGZAG8};
use deckcraft_bitstream::BitReader;
/// Scaling matrices after applying fall-back rules, stored in raster order (weightScale).
+1 -1
View File
@@ -301,7 +301,7 @@ fn plane_allocator() -> fn(usize) -> Vec<u8> {
fn copy_line<const N: usize>(line: &[u8], x0: i32, out: &mut [u8], w: usize) {
if w <= N
&& x0 >= 0
&& let Some(src) = line.get(x0 as usize..).and_then(|l| l.first_chunk::<N>())
&& let Some(src) = line.get(x0 as usize..).and_then(<[_]>::first_chunk::<N>)
&& let Some(dst) = out.first_chunk_mut::<N>()
{
*dst = *src;

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