Advancing rendered the new slide on the UI thread at full screen size
before anything changed on screen. While a slide rests (no animation or
transition playing) the show now renders the next slide's first frame on
a background thread; entering that slide uploads the ready image instead
of rendering, waiting for the render if it is still running. Slides with
animated GIFs and the web build keep rendering on entry.
Co-authored-by: Cursor <cursoragent@cursor.com>
Design Ideas showed the current slide once per built-in theme, and picking
one ran design.theme on the whole presentation; with a large picture every
idea looked the same. The pane also re-rendered eight slides every frame.
- New commands design.ideas (query) and design.applyIdea {id, slide?}
(cmd/ideas.rs). A slide with a picture gets full-bleed with the title on a
band, picture left or right with the text beside it, and a framed picture;
a text slide gets a title column, a centred title and a title at the foot.
An idea moves the slide's own shapes, crops the picture to cover its new
box, sends it to the back, and changes only that slide (one undo step).
- The pane shows the layout ideas first, then the themes under a "whole
presentation" heading, and caches the thumbnails until the deck revision,
slide or pane width changes.
Co-authored-by: Cursor <cursoragent@cursor.com>
Opening from the file picker or the Backstage recent list read and parsed
the file on the UI thread, so a large PPTX froze the window. The read now
runs on a thread (engine: read_presentation_file / add_presentation_file),
and a modal shows the file name, elapsed time and Cancel; Escape cancels
too. A cancelled read is dropped when it finishes. Scripted opens (control,
MCP, CLI) and the web build stay synchronous.
Co-authored-by: Cursor <cursoragent@cursor.com>
Pie and doughnut data labels still went through fmt_num, which rounded
anything non-integral to one decimal (999.99 drew as "1000.0"). They now
use Chart::data_label like the other label paths (series format, else
axis format, else General), and fmt_num is gone.
Co-authored-by: Cursor <cursoragent@cursor.com>
Per-axis size caps let AV1 accept 65536x65536 frames (over 8 GiB of luma)
and did not count references or pictures in flight. A shared budget module
in deckcraft-bitstream sizes a picture with checked arithmetic, and the
AV1, VP9, HEVC and H.264 decoders refuse a picture size whose current
picture, reference slots and in-flight pictures would exceed 4 GiB (1 GiB
on 32-bit), before allocating. set_memory_budget lowers it for hosts and tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
Shape ids are local to one presentation, and decoded audio was cached by the
address of a buffer the cache did not keep. Switching decks, closing the last
one, or replacing a clip could keep the previous sound. Playback now stops
when the document changes or closes, a new source replaces the voice's audio,
and the decode and duration caches hold the bytes they are keyed by.
Co-authored-by: Cursor <cursoragent@cursor.com>
Document uids started at 1 in every process, so two instances sharing
the recovery folder overwrote and deleted each other's entries, and
file.recovery.open deleted each entry as soon as it was loaded, leaving
nothing if the app died before the next checkpoint.
- Uids are a random per-process base plus a counter (below 2^53).
- Each instance holds a lock on owner-<id>.lock in the folder; entries
record their owner, and recovery open skips a running owner's entries
and removes stale lock files.
- Opening an entry no longer deletes it: the recovered presentation keeps
the entry's uid and takes ownership, so the snapshot stays until the
next recovery save replaces it or a save/close removes it.
- Entry files and metadata are both written via temp file + rename.
Co-authored-by: Cursor <cursoragent@cursor.com>
The film-grain header accepted scaling points whose values didn't strictly
increase, and building the scaling lookup then divided by zero. In threaded
decoding the panic left the frame's reference and picture slots unset, so
the output and dependent frames waited forever.
- The header rejects Y/Cb/Cr point values that don't strictly increase
(spec 6.8.20).
- The scaling lookup moves into scaling_lut, which skips a non-increasing
pair and uses checked indexing (defence in depth).
- run_job decodes inside guarded (catch_unwind), so a panic becomes an error
published to the picture and reference slots.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Charts: every category/value/size cache draws from one chart-wide
budget of 1M decoded slots, so sparse ptCount/idx declarations across
many series can't expand far beyond the XML.
- Chart parts go through the same 96 MiB XML size guard as other XML.
- Package::open charges every inflated byte, including parts it then
discards, against a 4 GiB decompression budget and stops reading
entries once it is spent.
- Legacy OLE previews index each VML drawing once (shape id -> picture
part) instead of reparsing it and its rels for every object.
Co-authored-by: Cursor <cursoragent@cursor.com>
The browser file picker only queued a name and bytes. When the read
finished, DeckCraft guessed the command and ran it on whichever
document and slide were active, so a picture, sound or outline could
land in the wrong place, and a later pick could finish first.
The request now keeps the document, the slide or master, the operation
and a cancellation id. A completion applies there, or is dropped when
that target is gone or the picker was dismissed.
Co-authored-by: Cursor <cursoragent@cursor.com>
decode_at accepted up to 400 million pixels and then called to_rgba8, which
the decoder's allocation limit doesn't cover (a grey image converts to four
times its decoded size), and every picture that failed to decode was kept,
bytes and all, for the life of the process.
- Raster decodes check the RGBA size (512 MiB cap) from the header before
decoding and again before the conversion.
- The failed-source cache keeps at most 256 entries and 64 MiB of encoded
bytes, oldest forgotten first; a larger failed input isn't kept, and a hit
must be the same Arc.
Co-authored-by: Cursor <cursoragent@cursor.com>
Currency and percent signs in right-to-left runs looked ahead through the
whole ET run for every sign, and each neutral span searched both ways for
the nearest strong direction. Both are now precomputed or carried along, so
a long run of signs or many short neutral spans cost linear work. The old
code stays in the tests as a reference and 4000 random paragraphs match it.
Co-authored-by: Cursor <cursoragent@cursor.com>
A shape path gradient tested every raster pixel against every flattened
outline edge, so an outline with tens of thousands of segments cost
billions of tests and stalled rendering. The outline is now simplified
before rasterising so pixels x edges stays under 64M: points closer than
a raster pixel are dropped first, then every k-th point is kept, with
each polygon left closed and at least 8 edges.
Co-authored-by: Cursor <cursoragent@cursor.com>
Paste added clashing media under a fresh id but left the pasted shapes on the
old id, reid did not touch connector ends despite its comment, pasted slides
kept animations on the original shape ids, and slide.duplicate remapped
animation targets but not triggers.
- reid returns the old-to-new shape map; connector ends on a copied shape
follow the copy, ends on a shape outside the copied set are detached.
- remap_animations points targets and triggers at the copies (an animation
whose target wasn't copied is dropped, an uncopied trigger cleared); used
by slide paste and slide.duplicate.
- bring_media returns the media ids it had to change and paste rewrites the
shapes (and a pasted slide's picture background) to use them; slide copy
now also carries a picture background's media.
Co-authored-by: Cursor <cursoragent@cursor.com>
Row and column insert/delete ignored merged cells, leaving covered cells
with no anchor or new cells overlapping an existing span. These edits now
read the merged areas first, shift or resize each one (a row or column
inserted inside a merge widens it, one deleted from it narrows it), move
the anchor and its text to the next covered cell when the anchor row or
column is deleted, and rebuild spans and merge flags from the result.
Co-authored-by: Cursor <cursoragent@cursor.com>
Timeline::with found trigger sequences with Vec::contains and then rescanned
every animation once per sequence, and checked MAX_EFFECTS only after a
match, so a slide with many distinct triggers cost quadratic work before the
effect cap could stop it.
Animations are now grouped once (an index by sequence, first-appearance order
kept, main first), the build stops at MAX_EFFECTS before the next sequence,
and paragraph groups are deduplicated with a set instead of Vec::contains.
Co-authored-by: Cursor <cursoragent@cursor.com>
A cpal stream error was only kept and logged on stop, and the player kept
`streaming` true, so a device that died mid-play froze the voice and video.
AudioOut gains `failure()` (CpalOut reports DeviceNotAvailable), and
Player::tick also treats a stream with no callbacks for a second while a
voice plays as stalled. Either way the stream is stopped, voices continue on
the wall clock, and the next play retries the device; after three failures
the player gives up on the device and plays silently.
Co-authored-by: Cursor <cursoragent@cursor.com>
Ctrl+C and Ctrl+X ran edit.copy / edit.cut and handed the returned text
to egui's clipboard; the ribbon's Copy and Cut, the slide's right-click
menu, the menus, the palette and the control channel ran the same
commands through SlideApp::run, which dropped the text, so it never left
DeckCraft. SlideApp::run now keeps the text of a successful Copy or Cut
and hands it to the clipboard on the next frame, like pending URLs.
Regression test: copy_and_cut_commands_reach_the_system_clipboard runs
both commands and checks the frame's CopyText output (it fails without
the fix).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The row count came from 1/(1.5*rh) - the row spacing alone - and
ignored how far a settled hexagon actually reaches. On a 16:9 slide
the eleven rows stopped at ~75% of the height: from t~0.9, when every
cell has settled, the bottom quarter was plain black until the t=1
endpoint snapped to the full new slide.
The count now derives from the real geometry: rows sit 0.866*rh apart
and the flat bottom zone of a settled hexagon reaches 0.289*rh past
its centre, so counting to the flat-zone reach covers the slide for
every aspect ratio - counting to the vertex reach still left small
V-notches of black above the bottom edge on about a third of them
(US Letter, square, ...). 16:9 gains four rows (15, was 11), 4:3
seven (20, was 13).
Tests: at t=0.99, when every cell is settled and the seams are nearly
closed, a 0.02-step grid over the slide must sit inside settled
incoming cells (padded 1% past the hairline seams) on five aspect
ratios including US Letter and square. Fails on the old count at
(0.2, 0.74); before/after frames rendered with render-transition.
Cmd/Ctrl+L and the on-screen button draw the show's laser colour at the pointer. The pen and the laser are not on together, and a click does not advance the slide while the laser is up.
Co-authored-by: Cursor <cursoragent@cursor.com>
Searching for Export ran file.export with an empty object and reported a missing path. The palette now offers only commands that accept that call, so Export opens the dialog.
Co-authored-by: Cursor <cursoragent@cursor.com>
A deck that embeds a TrueType or OpenType face, including one wrapped as Embedded OpenType, now draws with that face and saves it back. MicroType Express subsets stay in the file and still draw in a stand-in.
Co-authored-by: Cursor <cursoragent@cursor.com>
alloc_id bumped next_id with saturating_add: a presentation whose
next_id reached u32::MAX (a hostile file can arrive that way;
fix_next_id imports it) handed out that same id to every new slide,
shape, layout and media - duplicate ids, ambiguous references, silent
corruption on every later insert.
alloc_id now returns Option<u32> via checked_add, and every caller
refuses: the engine's insert/duplicate/media/layout commands answer
with a clear error naming the command, paste and duplicate re-id
helpers propagate it, media addition is fallible, and a fresh slide is
Option. The pptx export's synthesized blank layout is guarded the same
way. Loading keeps such a deck viewable: repair re-ids duplicate
slides while it can, and a duplicate it cannot re-id stays as it is -
growth is refused, and only that duplicate keeps its ambiguous id.
Renumbering the loaded graph was rejected: it needs the
cross-document reference remapping that issue #406 tracks.
Tests: model exhaustion (alloc_id None, fresh media refused, a new
slide refused), engine (slide.new, shape.insert, slide.duplicate and
edit.duplicate all refuse; the document holds no duplicate ids),
format repair (an unre-id-able duplicate stays visible).
INTERSECTCLIPRECT, EXCLUDECLIPRECT and EXTSELECTCLIPRGN limit what later records paint, including a region made of several rectangles. A rotated map stores the bounds of the clip rectangle.
Co-authored-by: Cursor <cursoragent@cursor.com>
Every colour menu ends with Eyedropper. Choosing it closes the menu; the pointer is a
crosshair over the slide with the colour under it and its hex beside it, a click takes
the colour (slide.colorAt) and Esc, a right click or a click off the slide cancels.
The colour comes back through the menu that asked, which opens for a frame and returns
it as if its swatch had been clicked, so Shape Fill, Shape Outline, Font Color, the
Format pane and the other callers all apply it their own way without changes.
A read-only command, {slide?, x, y (slide units), scale?}: the slide is rendered and
the pixel at the point is returned as {color: "RRGGBB", alpha, x, y}. This is what the
Eyedropper takes, and what scripts and MCP use to read a colour off a slide. A point
outside the slide, a missing or non-numeric coordinate or an unknown slide is an error.
The start screen reported errors through set_status, but the status bar
is only drawn while a presentation is open: clicking a Recent entry
whose file had moved or been deleted did nothing visible, and the same
went for every other start-screen error, like a failed sample open.
The start screen now draws the transient status message itself: a real
label, so screen readers see it too, wrapped so a long OS error does
not clip at the panel edge. Recent entries whose file no longer exists
are dimmed with a "(missing)" mark before anything is clicked, each
entry has a remove button, and a successful open clears a stale error.
Existence is probed once per path per session; a stat on a dead network
share can block for the OS timeout, so UNC paths and URLs are never
probed and are judged when opened instead.
The remove button is a plain multiplication sign: U+2715 has no glyph
in the UI font stack and egui would draw the replacement character.
Tests: the missing mark and the drawn error (kittest, clicked through
the real UI), removal from the list, and the negative case - an
existing file is not marked.
The writer emitted the legacy comment part only: replies were flattened
into sibling comments, the resolved flag was dropped. Modern PowerPoint
decks carry a second, threaded part (p188) as well - and DeckCraft read
both, importing every comment twice.
Export now writes the modern threaded part (p188:cmLst, with the slide's
commentRel anchor ext and the modern authors part) next to the flattened
legacy part, the same pair of parts PowerPoint itself writes: older
readers keep the legacy view, PowerPoint 2021+ and DeckCraft read
threads, replies and resolved state. The reader prefers the modern part
when both are present and scales modern positions as EMU instead of
legacy 1/8 pt, so real PowerPoint decks stop double-importing their
comments and keep their marker positions.
Comment ids are deterministic GUIDs from the existing tables::guid, so
exports stay reproducible without a new dependency.
Known limitation for a follow-up: comments anchor with
p188:unknownAnchor (schema-valid) rather than a slide moniker, which
needs part identity the model does not have yet.
Tests: threads plus resolved state round-trip on top-level comments and
replies, both parts and the anchor-rel wiring asserted in the raw
package, part numbering with comment-free slides skipped, legacy-only
and modern-only packages import each with their own position units, and
XML-special characters survive the trip.
A shape with scene3d and sp3d but no bevelT was drawn in its flat fill
colour. PowerPoint lights the front face from the light rig whether or
not there is a bevel, so a plain 3-D rotated rectangle is lighter than
its fill. Model the face with the same threePt approximation as the
bevel (no rim), for solid fills.
Wild 3-D slides: s8 6.35 -> 4.26, s9 19.74 -> 17.68. Corpus unchanged
except lo tdf114848 s4 2.13 -> 1.94.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A shape seen through a 3-D camera preset (isometric, perspective...) or
turned with a:camera/a:rot drew flat and front-on. PowerPoint turns the
shape's plane in space and projects it. Fitted black-box to its exports
(a picture with coloured corner markers under every preset and several
rotations; every fit within half a point on a 100 x 75 pt shape): points
about the centre are turned Rz(-rev) Rx(lat) Ry(lon), perspective cameras
then dividing by D / (D - z) with D about 5-16 shape sizes; a:rot turns
the other way about the horizontal and vertical axes. DeckCraft now draws
the shape under the affine transform that best fits its turned corners:
exact for orthographic cameras, within 2% of the size for perspective
ones (an affine map can't foreshorten). Oblique cameras leave a flat
shape as it is; depth (extrusion) is not drawn.
Wild synthetic set: camera presets 15.02 -> 6.35, 3-D picture styles
16.00 -> 7.72, extruded cameras 21.41 -> 19.74.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The magnetic disk's and drum's end ellipses were half the size of
PowerPoint's: ECMA-376 makes them h/6 high (disk) and w/6 across
(drum); measured on PowerPoint's outlines.
- The moon's inner edge was a half ellipse of the box's height; the
standard's is an arc of a larger ellipse through the right corners and
(adj * w, middle) whose centre lies right of that point (radius g18w
across, dy1 down), and so it is fuller. Text rectangle from the standard
too.
Wild synthetic set: preset galleries 3.76 -> 3.47, 3.45 -> 3.21,
4.31 -> 4.00 and others slightly.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A reflection on a picture (PowerPoint's "reflected" picture styles) drew
nothing: the reflection painted the shape's fill, and a picture has none.
It now mirrors the picture itself, clipped to its outline, under the same
fade.
Wild synthetic set: picture effects slide 16.77 -> 16.00 (the rest is
3-D camera rotation, not modelled).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A shape's picture fill marked not to rotate with the shape turned and
flipped with it (rotWithShape was neither kept nor written: always 1).
PowerPoint keeps such a picture upright, stretched (or tiled) over the
box the rotated shape covers on the slide and clipped to the shape. Now
read, written (PictureFill::upright) and drawn so.
Wild synthetic set: picture fills on rotated/flipped shapes 12.89 ->
3.87.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Five-, six-, seven- and ten-pointed stars used their own normalisation
and came out narrower or shifted against PowerPoint's. ECMA-376 places a
star's points on an ellipse around (hc, vc * vf) with radii half the box
times hf / vf (star5 1.05146 / 1.10557, star6 1.15470, star7 1.02572 /
1.05210, star10 1.05146), so the points touch the box's edges; now done
for all stars (checked exactly against PowerPoint's outlines at three
sizes and inner-radius adjusts).
Wild synthetic set: star galleries 4.08 -> 3.76, 3.77 -> 3.45,
4.62 -> 4.31 and the adjust galleries slightly.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>