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";
2439import { DOMParser as XmlParser } from "@xmldom/xmldom" ;
2540import { parseHTML } from "linkedom" ;
2641import { 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" ;
2844import { createDeck } from "../src/lib/slide/ops" ;
2945
3046const h = harness ( "verify-slide-pptx" ) ;
@@ -67,7 +83,7 @@ deck.slides = [{
6783deck . background = "#FFFCF0" ;
6884const 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" ) ;
7187const zip = unzipSync ( result . bytes ) ;
7288const names = Object . keys ( zip ) ;
7389h . 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");
126142h . ok ( ! slide . includes ( 'name="Eyed"' ) , "a member of a group hidden by its Layers eye is left out, as in the PDF and HTML" ) ;
127143h . 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)"' ) && / < s v g o v e r f l o w = " v i s i b l e " / . 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 ) => / ^ p p t \/ s l i d e s \/ s l i d e \d + \. x m l $ / . 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 ( / n a m e = " ! ! T i t l e t e x t " / . test ( p1 ) && / n a m e = " ! ! S c a t t e r " / . test ( p2 ) , "objects on a Morph chain carry the same !!name on every page" ) ;
192+ h . ok ( / < m c : C h o i c e [ ^ > ] * R e q u i r e s = " p 1 5 9 " > < p : t r a n s i t i o n [ ^ > ] * p 1 4 : d u r = " 2 0 0 " [ ^ > ] * > < p 1 5 9 : m o r p h [ ^ > ] * o p t i o n = " b y O b j e c t " \/ > < \/ p : t r a n s i t i o n > < \/ m c : C h o i c e > < m c : F a l l b a c k > < p : t r a n s i t i o n s p d = " f a s t " > < p : f a d e \/ > < \/ p : t r a n s i t i o n > < \/ m c : F a l l b a c k > / . 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 ) => / \. ( x m l | r e l s ) $ / . 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 ---------------------------------------------------------
130246h . 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