Skip to content

Commit ffb511b

Browse files
committed
Merge remote-tracking branch 'origin/main' into sync-origin
2 parents 96a2304 + 4828c9b commit ffb511b

8 files changed

Lines changed: 508 additions & 61 deletions

File tree

‎docs/modes/slide.qmd‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ Video clips belong to Slide mode; Figure canvases continue to support static con
244244

245245
# Export
246246

247-
**Export ▾** offers three formats, each written to the project's `exports/`:
247+
**Export ▾** offers these formats, each written to the project's `exports/`:
248248

249249
- **HTML** — **one self-contained `.html`** with every slide, beat, animation, transform, and
250250
font, playing in any web browser with the same keys as Present mode. No Flux on the
@@ -253,16 +253,38 @@ Video clips belong to Slide mode; Figure canvases continue to support static con
253253
a copy to send before the talk (`<deck>.pdf`). Plots and text stay sharp at any zoom.
254254
- **PDF, each step** — one page per build step, so a slide that reveals three things becomes
255255
three pages (`<deck>-steps.pdf`).
256-
- **PowerPoint** — an editable `.pptx` (`<deck>.pptx`). Text arrives as real text boxes (bold,
257-
italic, colour, superscript and subscript kept), and rectangles, ellipses, lines, arrows and
258-
drawn paths as real PowerPoint shapes you can restyle. Plots arrive as **vector** pictures:
256+
- **PowerPoint** — an editable `.pptx` (`<deck>.pptx`) that **plays your builds and
257+
transitions** in PowerPoint's slide show. Text arrives as real text boxes (bold, italic,
258+
colour, superscript and subscript kept), and rectangles, ellipses, lines, arrows and drawn
259+
paths as real PowerPoint shapes you can restyle. Plots arrive as **vector** pictures:
259260
right-click one and choose **Convert to Shape** (**Group ▸ Ungroup** in older versions) to
260261
edit its individual lines, points and labels.
262+
- **PowerPoint, final state** — the same, but one PowerPoint slide per slide with every build
263+
step applied and no animation (`<deck>-final.pptx`): the easiest file to edit further.
264+
265+
**How builds play in PowerPoint.** PowerPoint's **Morph** transition tweens every object that
266+
appears under the same name on two consecutive slides, so it can carry what Flux animates —
267+
moves, Changes of size, colour or text, the camera, entrances and exits. A slide with builds
268+
becomes its resting state plus **one PowerPoint slide per click**, each entered by Morph over
269+
the effect's duration:
270+
271+
- effects that play one after another within a click become extra slides that advance by
272+
themselves after the same gap; an **auto** step advances after its delay; steps chained
273+
**with previous** land on the same click;
274+
- entrances and exits fade; **Rise in** and **Pop in** keep their lift or pop, and **Pop
275+
out** its shrink;
276+
- the slide's transition (fade, slide, push) plays when the slide is entered.
277+
278+
Objects are named with a leading `!!` (the Selection Pane shows it) — that is how Morph pairs
279+
them across slides, so keep the names if you edit a build. Morph uses its own ease, so a
280+
step's easing is not copied; a plot whose parts build in cross-fades from one state to the
281+
next, and a count-up cross-fades from its first number to its last. Morph needs PowerPoint 2019
282+
or Microsoft 365; older versions show a plain fade.
261283

262284
Pages and slides are exactly the stage's shape; PowerPoint slides are the standard 13.33-inch
263-
width. These formats cannot play, so videos show their poster frame and animations show where
264-
they end; a PowerPoint slide shows each slide with every build step applied. Fonts that are not
265-
installed on the other computer (such as Gelasio) are replaced by PowerPoint.
285+
width. Videos show their poster frame in PDF and PowerPoint, and PDF pages show where
286+
animations end. Fonts that are not installed on the other computer (such as Gelasio) are
287+
replaced by PowerPoint.
266288

267289
**Video…** exports the **current slide** and all of its animation steps as one continuous
268290
`.mp4`. Set **Delay between steps**, **Hold at start**, and **Hold at end**, then choose

‎scripts/verify-manifest.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2030,8 +2030,8 @@
20302030
]
20312031
},
20322032
{
2033-
"$why": "2026-09-24 slide PDF export (one page per slide / per build step) and the content-scale applicability note. PowerPoint export (native shapes, SVG plots).",
2034-
"glob": "{src/lib/slide/export/deckPdf.ts,src/lib/slide/export/deckPptx.ts,src/lib/slide/embedRender.ts,src/lib/plot/compensate.ts,src/lib/plot/store.ts,src/lib/interact/elementProperties.ts,src/lib/Inspector.svelte,electron/ipc/staticPrint.cjs}",
2033+
"$why": "2026-09-24 slide PDF export (one page per slide / per build step) and the content-scale applicability note. PowerPoint export (native shapes, SVG plots; builds as Morph pages, 2026-09-27).",
2034+
"glob": "{src/lib/slide/export/deckPdf.ts,src/lib/slide/export/deckPptx.ts,src/lib/slide/export/pptxBuilds.ts,src/lib/slide/embedRender.ts,src/lib/plot/compensate.ts,src/lib/plot/store.ts,src/lib/interact/elementProperties.ts,src/lib/Inspector.svelte,electron/ipc/staticPrint.cjs}",
20352035
"run": [
20362036
"verify-slide-pdf.ts",
20372037
"verify-slide-export-menu.mjs",

‎scripts/verify-slide-export-menu.mjs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
// (2026-09-24, owner requests) — the GUI half of verify-slide-pdf.
33
//
44
// • Slide mode's Export button opens a menu with the interactive HTML and the
5-
// two PDF plans (one page per slide, one per build step); Escape and an
5+
// two PDF plans (one page per slide, one per build step) and the two PowerPoint
6+
// exports (builds as Morph, final state); Escape and an
67
// outside click close it. (Printing needs the desktop app's print worker, so
78
// the PDF itself is checked by verify-slide-pdf and the native probe.)
89
// • A placed graphic with no text or strokes (a PNG wrapped in SVG, like the
@@ -66,7 +67,7 @@ try {
6667
await page.click(".export-btn");
6768
await sleep(150);
6869
const items = await page.evaluate(() => [...document.querySelectorAll(".export-menu [data-export]")].map((b) => b.getAttribute("data-export")));
69-
assert(JSON.stringify(items) === JSON.stringify(["html", "pdf", "pdf-steps", "pptx"]), `Export offers HTML, PDF, PDF per step and PowerPoint (${JSON.stringify(items)})`);
70+
assert(JSON.stringify(items) === JSON.stringify(["html", "pdf", "pdf-steps", "pptx", "pptx-final"]), `Export offers HTML, PDF, PDF per step, PowerPoint (animated) and PowerPoint (final state) (${JSON.stringify(items)})`);
7071
await page.keyboard.press("Escape");
7172
await page.focus(".export-menu").catch(() => {});
7273
await page.evaluate(() => document.querySelector(".export-menu")?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })));

‎scripts/verify-slide-pptx.ts‎

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,23 @@
1414
// (custom geometry) are native shapes;
1515
// • a plot is a VECTOR picture: the SVG part plus the PNG fallback
1616
// (asvg:svgBlip), and the SVG is the plot's own markup, not a raster;
17+
// • an uncropped plot's picture grows to its painted extent, so an axis
18+
// label outside the plot frame is not cut (a crop still clips);
1719
// • a raster image keeps its crop; hidden (own flag or a group eye) and not-yet-born elements are out;
18-
// • the build's final step is what is written.
20+
// • the "final" export writes the build's last step;
21+
// • the "animated" export (pptxBuilds.ts, 2026-09-27 owner request: builds
22+
// and transitions carried, "maybe with the morph") writes the resting
23+
// state plus a page per click, entered by Morph (p159, fallback fade) with
24+
// the effect's duration, objects named "!!…" so Morph pairs them; a later,
25+
// disjoint effect in the same click is an auto-advancing page after the
26+
// gap; an automatic step advances after its delay; a with-previous chain
27+
// is one click; rise / pop entrances and pop exits get an invisible twin
28+
// at the start / end pose; the camera is a Morph of the whole view; the
29+
// slide transition (fade / slide -> Cover / push) enters each slide; media
30+
// shown on several pages is stored once.
31+
// Morph playback itself (tweened position, size, colour, transparency, the
32+
// camera, the auto-advance timing, picture cross-fades) was checked in
33+
// PowerPoint by rendering a probe deck to video.
1934
// Native checks (PowerPoint opens it without repair, shape kinds, render) were
2035
// run against the owner's deck through PowerPoint's COM interface.
2136
// Run: node --import tsx scripts/verify-slide-pptx.ts
@@ -24,7 +39,8 @@ import { unzipSync, strFromU8 } from "fflate";
2439
import { DOMParser as XmlParser } from "@xmldom/xmldom";
2540
import { parseHTML } from "linkedom";
2641
import { harness } from "./lib/harness.mjs";
27-
import { deckPptxBytes, pptxColor } from "../src/lib/slide/export/deckPptx";
42+
import { deckPptxBytes, pptxColor, transitionXml } from "../src/lib/slide/export/deckPptx";
43+
import { pptxPages } from "../src/lib/slide/export/pptxBuilds";
2844
import { createDeck } from "../src/lib/slide/ops";
2945

3046
const h = harness("verify-slide-pptx");
@@ -67,7 +83,7 @@ deck.slides = [{
6783
deck.background = "#FFFCF0";
6884
const payload = { deck, plots: { "plot-asset": { svg: PLOT_SVG, manifest: PLOT_MANIFEST } }, assets: { "png-asset": PNG }, assetSizes: { "png-asset": { width: 40, height: 20 } } };
6985

70-
const result = await deckPptxBytes(deck.title, [{ payload }], rasterize);
86+
const result = await deckPptxBytes(deck.title, [{ payload }], rasterize, undefined, "final");
7187
const zip = unzipSync(result.bytes);
7288
const names = Object.keys(zip);
7389
h.ok(result.slides === 1 && result.warnings.length === 0, `one slide, no warnings (${JSON.stringify(result.warnings)})`);
@@ -126,6 +142,106 @@ h.ok(!slide.includes('name="Hidden"'), "a hidden element is left out");
126142
h.ok(!slide.includes('name="Eyed"'), "a member of a group hidden by its Layers eye is left out, as in the PDF and HTML");
127143
h.ok(slide.includes('name="Late"'), "the element born at the last step is in (the final state is written)");
128144

145+
// ---- a plot's ink past its frame (2026-09-27, owner report: an axis label that
146+
// sits outside the plot frame was cut in the .pptx only) ----------------------
147+
// The measurer stands in for layout: the 200x150 plot paints from x -30 (a y
148+
// label left of the frame) down to y 175 (an x label below it).
149+
const measured: string[] = [];
150+
// "Inside" (ids prefixed inside__) paints exactly its frame.
151+
const measure = async (svg: string) => { measured.push(svg); return svg.includes('"inside__') ? { x: 0, y: 0, width: 200, height: 150 } : { x: -30, y: -5, width: 250, height: 180 }; };
152+
const inkDeck = createDeck({ id: "ink-deck", title: "Ink", withTitleSlide: false });
153+
inkDeck.stage = { width: 640, height: 360 };
154+
const plotAt = (id: string, name: string, extra: object) => ({ ...base, id, type: "plot", name, x: 400, y: 180, width: 200, height: 150, assetId: "plot-asset", overrides: {}, ...extra });
155+
inkDeck.slides = [{ id: "s1", name: "One", beats: [{ id: "b0", tracks: [] }], groups: {},
156+
elements: [plotAt("free", "Free", {}), plotAt("cropped", "Cropped", { crop: { x: 10, y: 10, width: 100, height: 80 } }), plotAt("turned", "Turned", { rotation: 30 }), plotAt("inside", "Inside", {})] }] as typeof inkDeck.slides;
157+
const inkZip = unzipSync((await deckPptxBytes(inkDeck.title, [{ payload: { ...payload, deck: inkDeck } }], rasterize, measure, "final")).bytes);
158+
const inkSlide = strFromU8(inkZip["ppt/slides/slide1.xml"]);
159+
const inkRels = strFromU8(inkZip["ppt/slides/_rels/slide1.xml.rels"]);
160+
const E = 12192000 / 640; // EMU per stage px
161+
const frameOf = (name: string) => inkSlide.match(new RegExp(`name="${name}"[^]*?<a:off x="(-?\\d+)" y="(-?\\d+)"/><a:ext cx="(\\d+)" cy="(\\d+)"/>`))?.slice(1).map(Number);
162+
const svgOf = (name: string) => {
163+
const rel = inkSlide.match(new RegExp(`name="${name}"[^]*?<asvg:svgBlip [^>]*r:embed="(rId\\d+)"`))?.[1];
164+
const target = inkRels.match(new RegExp(`Id="${rel}"[^>]*Target="\\.\\./media/([^"]+)"`))?.[1];
165+
return target ? strFromU8(inkZip[`ppt/media/${target}`]) : "";
166+
};
167+
const px = (v: number) => Math.round(v * E);
168+
// Ink -30..220 x -5..175, padded 2: the picture spans -32..222 x -7..177.
169+
h.ok(JSON.stringify(frameOf("Free")) === JSON.stringify([px(368), px(173), px(254), px(184)]),
170+
`an uncropped plot's picture grows to its ink, label included (${frameOf("Free")})`);
171+
const freeSvg = svgOf("Free");
172+
h.ok(freeSvg.includes('viewBox="0 0 254 184"') && freeSvg.includes('transform="translate(32 7)"') && /<svg overflow="visible"/.test(freeSvg),
173+
"its SVG is shifted into the grown picture and lets the plot overflow its frame, as the canvas does");
174+
h.ok(JSON.stringify(frameOf("Cropped")) === JSON.stringify([px(400), px(180), px(200), px(150)]) && !svgOf("Cropped").includes('overflow="visible"'),
175+
"a cropped plot still clips to its frame");
176+
// Rotated: grown evenly (32 each side across, 27 each side down) so the
177+
// picture turns about the plot's own centre.
178+
h.ok(JSON.stringify(frameOf("Turned")) === JSON.stringify([px(368), px(153), px(264), px(204)]) && inkSlide.includes('rot="1800000"'),
179+
`a rotated plot grows symmetrically about its centre (${frameOf("Turned")})`);
180+
h.ok(JSON.stringify(frameOf("Inside")) === JSON.stringify([px(400), px(180), px(200), px(150)]) && svgOf("Inside").includes('viewBox="0 0 200 150"'),
181+
`a plot whose ink stays inside keeps its frame exactly, no padding (${frameOf("Inside")})`);
182+
h.ok(measured.length === 3, `only the uncropped plots are measured (${measured.length})`);
183+
184+
// ---- animated: builds as Morph pages ------------------------------------------
185+
{
186+
const anim = unzipSync((await deckPptxBytes(deck.title, [{ payload }], rasterize)).bytes);
187+
const pageNames = Object.keys(anim).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n)).sort();
188+
const [p1, p2] = pageNames.map((n) => strFromU8(anim[n]));
189+
h.ok(pageNames.length === 2, `the default export is animated: the resting state plus one page for the click (${pageNames.length})`);
190+
h.ok(!p1.includes('name="!!Late"') && p2.includes('name="!!Late"'), "the element born on the click is absent before it and present after");
191+
h.ok(/name="!!Title text"/.test(p1) && /name="!!Scatter"/.test(p2), "objects on a Morph chain carry the same !!name on every page");
192+
h.ok(/<mc:Choice [^>]*Requires="p159"><p:transition [^>]*p14:dur="200"[^>]*><p159:morph [^>]*option="byObject"\/><\/p:transition><\/mc:Choice><mc:Fallback><p:transition spd="fast"><p:fade\/><\/p:transition><\/mc:Fallback>/.test(p2),
193+
"the click's page is entered by Morph over the effect's 200 ms, with a fade for readers without Morph");
194+
const svgs = Object.keys(anim).filter((n) => n.startsWith("ppt/media/") && n.endsWith(".svg"));
195+
h.ok(svgs.length === 1, `the plot shown on both pages is stored once (${svgs.length} SVG parts)`);
196+
const bad2: string[] = [];
197+
for (const name of Object.keys(anim).filter((n) => /\.(xml|rels)$/.test(n))) {
198+
const errors: string[] = [];
199+
new XmlParser({ errorHandler: { error: (m: string) => errors.push(m), fatalError: (m: string) => errors.push(m) } }).parseFromString(strFromU8(anim[name]), "application/xml");
200+
if (errors.length) bad2.push(`${name}: ${errors[0]}`);
201+
}
202+
h.ok(bad2.length === 0, `every part of the animated package is well-formed (${bad2.slice(0, 2).join(" | ")})`);
203+
}
204+
{
205+
// The planner on a slide with every build kind the export translates.
206+
const d = createDeck({ id: "builds", title: "Builds", withTitleSlide: false });
207+
d.stage = { width: 640, height: 360 };
208+
const box = (id: string, x: number, y: number) => ({ ...base, id, type: "rect", name: id, x, y, width: 100, height: 60, fill: "#205EA6", stroke: "none", strokeWidth: 0, cornerRadius: 0 });
209+
const tr = (target: string, preset: string, extra = {}) => ({ id: `${target}-${preset}`, target, preset, duration: 1000, easing: "linear", ...extra });
210+
d.slides = [{ id: "s", name: "Builds", transition: "slide", groups: {},
211+
elements: [box("rise", 0, 100), box("pop", 200, 100), box("mover", 0, 200), box("changer", 200, 200), box("leaver", 400, 0), box("with", 400, 200)],
212+
beats: [
213+
{ id: "b0", tracks: [] },
214+
{ id: "b1", tracks: [tr("rise", "fadeRise", { params: { y: 40 } }), tr("pop", "popIn", { params: { from: 0.5 } })] },
215+
{ id: "b2", tracks: [tr("mover", "move", { to: { x: 200, y: 0 } }), tr("changer", "transform", { start: 1500, duration: 500, to: { state: { width: 180 } } })] },
216+
{ id: "b3", advance: "auto", autoDelayMs: 800, tracks: [tr("leaver", "popOut", { params: { to: 0.5 } })] },
217+
{ id: "b4", tracks: [tr("@camera", "camera", { duration: 1500, to: { x: 320, y: 180, zoom: 2 } })] },
218+
{ id: "b5", advance: "with-prev", tracks: [tr("with", "fadeOut", { duration: 400 })] },
219+
] }] as typeof d.slides;
220+
const pages = pptxPages({ deck: d, plots: {}, assets: {}, assetSizes: {} } as never, "animated");
221+
const el = (i: number, id: string) => pages[i].ev.elements.find((e) => e.id === id)!;
222+
h.ok(pages.length === 6, `resting + click 1 + two phases of click 2 + the automatic step + the camera click with its chained beat (${pages.length})`);
223+
h.ok(pages[0].enter.kind === "cover" && pages[0].enter.ms === 320, "the slide is entered by its transition (slide -> Cover, the player's 320 ms)");
224+
h.ok(pages[1].enter.kind === "morph" && pages[1].enter.ms === 1000 && pages[0].advanceAfterMs === undefined, "click 1 is a 1 s Morph, reached by a click");
225+
const twinRise = el(0, "rise"), twinPop = el(0, "pop");
226+
h.ok(!twinRise.hidden && twinRise.opacity === 0 && twinRise.y === 140 && el(1, "rise").y === 100 && (el(1, "rise").opacity ?? 1) === 1,
227+
"a rising entrance starts as an invisible twin 40 px lower on the page before");
228+
h.ok(!twinPop.hidden && twinPop.opacity === 0 && twinPop.width === 50 && twinPop.x === 225, "a popping entrance starts as an invisible twin at half size, centred");
229+
h.ok(el(2, "mover").x === 200 && el(2, "changer").width === 100 && el(3, "changer").width === 180,
230+
"click 2's move lands on its first page; the later, disjoint change waits for the second");
231+
h.ok(pages[2].enter.ms === 1000 && pages[2].advanceAfterMs === 500 && pages[3].enter.ms === 500,
232+
"the second phase follows by itself after the 500 ms gap, as a 500 ms Morph");
233+
h.ok(pages[3].advanceAfterMs === 800 && pages[4].enter.kind === "morph", "the automatic step advances 800 ms after the previous one ends");
234+
const leaver = el(4, "leaver");
235+
h.ok(!leaver.hidden && leaver.opacity === 0 && leaver.width === 50, "a popping exit ends as an invisible half-size twin");
236+
h.ok(pages[5].ev.camera?.zoom === 2 && pages[5].enter.ms === 1500 && el(5, "with").hidden === true,
237+
"the camera click and its with-previous fade-out land on one page, a Morph over the longest effect");
238+
h.ok(pages.every((p) => p.names.get("mover") === "!!mover"), "every page names objects alike for Morph");
239+
const finalOnly = pptxPages({ deck: d, plots: {}, assets: {}, assetSizes: {} } as never, "final");
240+
h.ok(finalOnly.length === 1 && finalOnly[0].ev.camera?.zoom === 2 && finalOnly[0].names.get("mover") === "mover", "the final export is one page at the last state, plain names");
241+
h.ok(transitionXml({ kind: "none", ms: 0 }, 800) === '<p:transition advTm="800"/>' && transitionXml({ kind: "none", ms: 0 }) === "",
242+
"a page without an entrance effect can still advance by itself");
243+
}
244+
129245
// ---- colour parsing ---------------------------------------------------------
130246
h.ok(JSON.stringify(pptxColor("#abc")) === JSON.stringify({ rgb: "AABBCC", alpha: 1 }) && pptxColor("none") === null &&
131247
pptxColor("rgba(255, 0, 0, 0.5)")?.rgb === "FF0000" && pptxColor("rgba(255, 0, 0, 0.5)")?.alpha === 0.5,

0 commit comments

Comments
 (0)