Visual effect fidelity
Slides render as HTML/CSS/SVG rather than Canvas (sharp text at any zoom, native accessibility, DOM interactivity), so a few PowerPoint effects with no exact CSS/SVG equivalent are reproduced with a specific, deliberate technique instead. This page documents the techniques that have been checked against real PowerPoint output and hold up; see Limitations for effects that are still open gaps.
Reflections (a:effectLst/a:reflection)
Rendered as a mirrored sibling node that reflects the element's full rendered content: fill, outline and its own text body for a shape or picture, and every child (with its own fill/outline/text) for a group, recursively. @sx / @sy / @kx / @ky / @rot / @fadeDir / @algn are all honoured, in every one of the five bindings.
A group's own reflection (with no fill of its own) is honoured too, as are a group's own shadow/glow/soft edge, resolved onto the group's composite raster as a CSS filter (never a box-shadow, which would shadow the group's bounding rectangle instead of its content). A child inside a reflected group that itself carries a reflection is double-mirrored, matching how PowerPoint composites a group's reflection from the group's already-fully-rendered content (which includes the child's own reflection).
Soft edges (a:softEdge)
Rendered as an SVG filter that feathers only the shape's alpha edge (an erode, then a blur, then a composite back into the original fill), leaving the interior fill/text sharp rather than blurring the whole element.
COM-measured correction (2026-09-10). The original implementation fed the authored @rad value directly into a single feGaussianBlur(stdDeviation = rad) composited in the source graphic. That is wrong on both the shape and the width of the transition: a Gaussian blur of a hard edge is 50% opaque exactly AT the un-blurred boundary and does not reach full opacity until roughly 3 radii inward. Measured against real PowerPoint 2016 (Slide.Export PNG, a 1280x720 slide, a rectangle with a:softEdge rad="190500" = 20px and a second fixture at rad="381000" = 40px, sampling a horizontal scanline through the shape's edge):
| Authored radius | Opacity at the boundary | ~50% opacity | ~100% (saturated) |
|---|---|---|---|
| 20px | ~0% | ~18px inward (0.9x radius) | ~34px inward (1.7x radius) |
| 40px | ~0% | ~35px inward (0.88x radius) | ~72px inward (1.8x radius) |
Both radii confirm PowerPoint's own feather is near-transparent exactly at the authored boundary (not 50%) and reaches full opacity at roughly 1.75-1.8x the authored radius (not the ~3x a raw Gaussian blur implies), and the relationship scales linearly with the authored radius. The renderer now erodes the alpha inward by 0.9 x radius before a narrower blur (0.3 x radius, so its own 3-sigma spread is ~0.85x radius) feathers it, landing within about a pixel of both measured curves. Implemented once in packages/shared/src/render/visual-effects.ts (getSoftEdgeSvgFilter), consumed identically by all five bindings.
Path gradients (a:gradFill/a:path)
circle and shape path types render as a native elliptical/circular CSS/SVG radial gradient - PowerPoint's own render for those types is genuinely elliptical, so no approximation is needed.
The rect path type is different: PowerPoint shades toward the shape's own bounding rectangle, whose isolines are concentric rectangles with square corners (a Chebyshev/L-infinity distance field), which no native CSS or SVG radial gradient can produce (they are all elliptical). packages/shared/src/render/path-gradient-rect.ts renders the true rectangular field directly, as a stack of 40 nested, axis-aligned <rect> bands (largest first, smallest last).
COM-measured (2026-09-10). A fixture with an a:gradFill path="rect" (red/green/blue stops, a 30%-inset a:fillToRect) exported from PowerPoint 2016 (Slide.Export PNG) shows a perfectly smooth, continuous colour ramp with no visible banding at any sampled point (5px steps across the full gradient width), and the flat inner region lands exactly where the authored fillToRect inset places it. PowerPoint's own render has no discretisation to match; this renderer's 40 discrete bands are a deliberate trade-off against the alternative (no native squared-corner gradient primitive exists at all), and at 40 steps each visible band spans only a few CSS pixels even across a full-width gradient, which is not perceptible at normal zoom - confirmed by the same smooth-looking output on screen for the measured fixture. The underlying field shape (linear ramp along both axes, symmetric falloff, square isolines) matches PowerPoint's measured colour values exactly.
Pixelate transition filter (p:animEffect/@filter="pixelate")
Every SMIL filter family p:animEffect/@filter can name resolves to a real reveal/conceal effect, matched to PowerPoint's own playback. pixelate is the one family where that match is a snap to the end state rather than a gradual transition, because that is what PowerPoint itself shows.
COM-measured (e2e/fixtures/pixelate-filter.pptx, PowerPoint 2016, Presentation.CreateVideo, frame-diffed against an otherwise byte-identical control deck with filter="dissolve" swapped in). The dissolve control deck visibly dissolves: an early, mid-reveal frame differs pixel-for-pixel from a later, settled one. The pixelate deck does not: the earliest and latest frames of its click step are byte-for-byte identical, and the target shape is already fully painted at full opacity from the very first rendered frame of the step. PowerPoint performs no animation at all for filter="pixelate", the same way it treats a build effect it cannot interpret: it silently snaps straight to the resolved end state. pixelate is a schema-legal ST_TransitionFilterType value (ECMA-376 20.1.8.49) with no host implementation in real PowerPoint.
The renderer's default therefore resolves pixelate to the same cutIn/cutOut keyframes the p:animEffect/@filter="cut" family uses (a genuine instant swap, confirmed live: e2e/animation-pixelate-filter.spec.ts asserts the entrance plays pptx-cutIn, not a gradual reveal), matching PowerPoint's own behaviour rather than animating something PowerPoint never shows. A blocky, content-preserving mosaic reveal (packages/shared/src/render/animation-pixelate-filter.ts: self-contained SVG <filter> data-URIs stepped through discrete @keyframes stops, each visible cell showing the element's own real content) remains available as an explicit, off-by-default option - pixelateMosaicAnimation (File > Options > Advanced > Slide Show > "Show a mosaic effect for Pixelate transitions") - for a viewer that would rather show something animating than PowerPoint's own instant swap. Implemented once as a shared decision function (resolveFilterEffect in packages/shared/src/render/animation-filter-effects.ts, threaded through PresentationAnimationController.fromSlide's pixelateMosaic option), consumed identically by all five bindings.
Cinematic transitions (cube, box, flip, rotate, pageCurl, origami, ...)
This family animates via CSS keyframes (perspective / rotate / curl) on 2-D slide layers rather than a true volumetric 3-D render. That is the deliberate design: every preset in the family gets its own keyframe set tuned to reproduce PowerPoint's own on-screen motion shape, not a generic 3-D scene.
COM-measured against PowerPoint 2016 CreateVideo frames:
cube/rotateshare one screen-flush hinge (cubebare,rotateviaisContent="1"on the same<p14:prism>).box/orbitshare a depth-receding hinge that opens a gap and foreshortens both axes (boxviaisInverted="1",orbitviaisContent="1" isInverted="1").doors/windowhorzopens top/bottom.fallOvertopples the outgoing slide off a top hinge (not the incoming one).revealholds a genuine dark gap through the first half before the incoming slide fades in.warpis a radial zoom-blur burst, not a skew.crushcrumples toward the centre rather than a flat vertical squash.flythrough,gallery,ferris,conveyor,switch,pageCurl(single/double),peelOff,drape,ripple,flash,zoomandorigamiwere likewise COM-measured and match the keyframes' motion shape.vortex,honeycomb,glitter,shred,fracture,curtainsandairplaneare COM-confirmed to render as many independent fragments, tiles or particles in real PowerPoint (or, forairplane, an actual paper-plane silhouette fold); each now renders as a capped set of independently clip-path'd, transform/opacity-animated fragments built from the same measurement (getFragmentedTransitionDescriptorinpackages/shared/src/render/slide-transition-fragments.ts), not a single flat layer.
3-D shapes and scenes (a:sp3d / a:scene3d)
Camera presets map to a real CSS transform (transform-style: preserve-3d). For the perspective* and isometric* presets this is an exact COM-measured matrix3d homography (a convex-hull fit of the projected quad under every preset, normalised to the unit square and scaled to the element's rendered size), not an approximate rotation: the single-axis perspectiveAbove/Below/Left/Right family is a pure scale + offset with no keystone, and the two-axis *Facing/Contrasting*/Heroic* family's off-axis vanishing point is represented exactly. oblique*/legacyOblique*/legacyPerspective*/orthographicFront are COM-confirmed to leave a shape's front face flat; only an extruded shape's side panels respond to these legacy extrusion-direction cameras.
An explicit a:camera/a:rot/@fov/@zoom override builds the same kind of exact homography as the preset table, via a general parametric camera function (visual-3d-camera-parametric.ts). Extrusion depth renders as real translateZ side panels; which side (top/bottom/left/right) shows is COM-measured per preset (44 presets, an edge-band ink analysis). Contour renders as a real solid CSS outline ring at the authored width.
Bevel presets render as a real SVG lighting <filter> (visual-3d-bevel-lighting.ts): a height map built from the shape's own alpha silhouette is lit by feDiffuseLighting + feSpecularLighting under a feDistantLight, not a CSS box-shadow approximation. Material presets (a:sp3d/@prstMaterial) feed the same light instead of an independent CSS filter, and the a:lightRig/@rig elevation table is COM-calibrated across all 27 ST_LightRigType tokens. The scene's ground plane (a:backdrop) intentionally renders no shadow of its own, matching PowerPoint, which shows no ground-plane artifact without a shadow effect and no CSS-representable shape when one is present on a tilted backdrop. WordArt/text-body 3-D camera presets (a:bodyPr/a:scene3d) call the identical shape-level camera function.
Closed (2026-09-16). The relaxedInset/slope/hardEdge bevel-profile gap below is fixed: a non-monotonic height-remap stage now reproduces the measured bright-bump-then-dark-trough cross section. See the dedicated subsection below.
Closed (2026-09-24). Recalibrating the light-rig elevation had exposed a specular/diffuse coupling bug: metal's specular constants (tuned against threePt's old, assumed 45deg elevation) saturated to solid white across the shape's flat interior once threePt's real elevation (74deg, and the flat/contrasting family's 90deg) was measured. Two remediations were tried and rejected as regressions (a fixed low specular elevation regressed angle/softRound; masking the specular term to the curved band regressed every profile). The fix that landed: MaterialLighting.specularElevationCapDeg (visual-3d-bevel-lighting-material.ts) gives the specular <feDistantLight> its own elevation, Math.min(rigElevationDeg, cap), independent of the diffuse light's elevation, jointly re-fit with metal's specularConstant/specularExponent against COM ground truth (surfaceScaleMultiplier held fixed, since lowering it flattens the diffuse band too, a separate concern). Measured, headless-Chromium rendering of the real filter chain against 134 real-PowerPoint Slide.Export conditions (all 27 a:lightRig/@rig values x circle/angle, plus the original 8-rig x 12-profile set, dir="t", full 96-sample cross-section MAE, not just 2 edge points):
| mean abs error (0-255) | |
|---|---|
| coupled elevation (before) | 75.0 |
| decoupled + re-fit (after) | 36.4 |
The clamp is a no-op below the cap (morning, sunrise, and the rest of the already-low-elevation rigs are unaffected), and only metal carries a cap, so no other material's filter markup changed.
Non-monotonic bevel-profile height remap (relaxedInset/slope/hardEdge)
Closed 2026-09-16. The three profiles whose COM cross-section is a genuine bright-bump-then-dark-trough double transition (see the profile-shape campaign above) now get a dedicated reshaping stage instead of forcing the shared blur(+erode) height ramp to approximate one. visual-3d-bevel-lighting-profile.ts's BEVEL_PROFILE_HEIGHT_MAP entries for these three carry a heightTransferTable: an ordered array of stops fed into an feComponentTransfer/feFuncA type="table" primitive, inserted between the existing feGaussianBlur(+feMorphology erode) height ramp and feDiffuseLighting/feSpecularLighting (visual-3d-bevel-lighting.ts's renderLayerPrimitives). Because feFuncA remaps the height VALUE pointwise, a non-monotonic table reshapes the height field's own spatial derivative (and therefore its lit surface normal) into a genuine two-lobe profile, even though the underlying blur/erode ramp it remaps is still a single monotonic ramp. This needed no new geometry primitive, just a reshaping stage.
Each profile's table is that profile's own pinned 24pt COM cross-section curve (visual-3d-bevel-lighting-tables.test.ts's MEASURED_24PT_CURVES, 10 points from the top edge inward), normalised to [0, 1] via (brightness - min) / (max - min):
| Profile | Normalised table (10 stops, edge -> interior) |
|---|---|
relaxedInset | 0.671, 0.958, 1.0, 0.975, 0.0, 0.320, 0.839, 0.924, 0.924, 0.924 |
slope | 0.519, 1.0, 0.967, 0.960, 0.0, 0.426, 0.100, 0.960, 0.960, 0.960 |
hardEdge | 0.279, 1.0, 0.938, 0.938, 0.0, 0.040, 0.793, 0.938, 0.938, 0.938 |
What this is and is not. This is a deliberate, documented approximation, not a claim of an inverted lighting model: for a gently-curved, primarily-diffuse height field, rendered brightness tracks local height fairly directly, so reusing the measured brightness curve's own shape as the height-remap curve reproduces the measured peak/trough/recovery POSITIONS closely without solving the diffuse/specular equations backwards. It was verified two ways:
- Structurally (
visual-3d-bevel-lighting-tables.test.ts,visual-3d-bevel-lighting.test.ts): each table is a genuine non-monotonic sequence (an interior peak at the argmax, followed by a meaningful trough, at least 0.3 lower on the table's own 0-1 scale, followed by recovery), each table is derived directly from this file's own pinnedMEASURED_24PT_CURVES(a regression test recomputes the normalisation and fails if the pin and the table ever drift apart), the generated SVG filter markup carries the newfeComponentTransfer/feFuncAstage feeding into the lighting primitives ONLY for these three profiles, and the other 9 profiles (already within RMSE 1-7 of their measured curve without this stage) are unchanged. - Not re-verified against a fresh COM render: no new
Slide.Exportmeasurement was taken for this change. The existing 2026-09 pins are the ground truth reused here, so treat the exact on-screen brightness as an analytical/geometric fit against already-measured data, rather than a newly pixel-verified RMSE number the way the profile-shape campaign above quotes one.
Measurement provenance
- Camera homography: a follow-up 27-point
latxlonxrevCOM grid (2026-09) replaced the projection's combined-axis term with an exact rotation-composition cross term, landing within an average 0.61% corner error across the 27 points (worst 2.1%), closing a prior ~25-29% residual left by a campaign with only one measurement per case.lon's sign is COM-confirmed directly;lat's sign cannot be observed from a single-axis case (cosis even) but is confirmed jointly withlonby the 27-point grid;rev's sign was isolated with its own 45deg-roll COM case, matching to within about 10deg across 4 corners. - Extrusion panels: measured for 44 homography-covered presets via edge-band ink analysis; the panel's own projected shape is COM-validated for the 37 presets with visible ink (the back edge is the front edge translated by a COM-measured, depth-linear screen-space vector);
perspectiveFront,orthographicFront,perspectiveLeft/Right,obliqueLeft/RightandperspectiveHeroicExtremeLeftFacingshow no panel in PowerPoint at all. - Bevel highlight direction: COM-confirmed across 9 of 12
a:bevelTprofiles as a cardinal-snap ofa:lightRig/@dir;softRoundmeasures the opposite cardinal edge;slope/hardEdgeshow no clean directional brightness signal (see gap 1 above). - Bevel height-map shapes: a 2026-09 campaign fit real cross-section brightness curves (40 points, 6pt and 24pt bevels, all 12 profiles) against the filter's existing
blurFactor/morphologyFactor/surfaceScaleFactorknobs, landing a close fit for 9 of 12 profiles (circle,convex,softRound,divot,angle,cross,coolSlant,riblet,artDeco); seevisual-3d-bevel-lighting-tables.ts'sBEVEL_PROFILE_HEIGHT_MAPdoc comment and its.test.tsfor the full table and pinned curves. The remaining 3 (relaxedInset/slope/hardEdge) were closed 2026-09-16 via aheightTransferTablereshaping stage fit directly from these SAME pinned curves (no new COM render); see the dedicated subsection above. - Materials: COM-measured across
circle/angle/hardEdge/softRoundx 4 cardinal directions xmatte/metal(32 conditions, sampled 0.15in from each edge). Mean absolute brightness error dropped from a 56.3 (matte) / 61.2 (metal) box-shadow baseline to 34.8 (matte) with the SVG filter;metalneeded recalibration (raiseddiffuseConstant, loweredsurfaceScalemultiplier) to bringangle/hardEdge/softRoundat or below baseline in every direction.metal/circlealone resisted (COM measures shadow-side reflectance above whatfeDiffuseLighting'sN.L<=0clamp can structurally produce) until refittingcircle's own height-map profile brought its error to 39.5 against the 54.9 baseline, confirmed by fresh COM measurement and a headless-Chromium render of the actual filter;angle/hardEdge/softRoundwere re-checked the same way at 41.5/62.0/27.5 against baselines of ~70/~69.5/~50. - Light rig elevation: all 27
ST_LightRigTypetokens were measured (mid-greymatte/circlesquare, 0.15in-from-edge sampling, independentdir="t"/dir="r"campaigns agreeing to under 1 brightness unit). Ten rigs (harsh,balanced,twoPt, the*4variant of eachlegacy{Flat,Normal,Harsh}family, and the 5 weather rigs) measure their highlight on the edge oppositea:lightRig/@dir; the 4 numbered legacy variants are not interchangeable (*1/*3are non-directional at this sample offset,*2/*4are directional mirror images). A separate campaign sampling the exact centre of a mid-greycircle-bevel square acrossmatte/metal/plasticand 15 rig values (elevation 54-90deg) measured all three materials pixel-identical at every rig, confirming the material response is genuinely zero on a flat normal at every elevation, not just low ones. - Contour and the highlight/shadow band width scale with bevel depth (6pt vs 24pt) are COM-confirmed;
extrusionClr/contourClrcolours are honoured. Backdrop shadow: COM-measured with a shape carrying a reala:outerShdwplus a scene3d backdrop; PowerPoint shows no visible ground-plane artifact without a shadow effect, and even with one, a level/near-level backdrop is indistinguishable from none, while a strongly tilted backdrop projects a non-convex sheared shape no CSSbox-shadowcan represent. - WordArt/text-body camera: COM-verified for
perspectiveHeroicLeftFacingandperspectiveLeft(within ~1% of the shape measurement, via a convex hull of a filled-glyph "ink" rectangle); visually confirmed forisometricTopUp(its 45deg roll made the automated corner-fit unreliable enough that no percentage is quoted).
WordArt envelope glyph-outline warping (a:prstTxWarp inflate/deflate/can)
The inflate/deflate/can "envelope" family of WordArt presets bends text between an independent top and bottom curve, so a glyph's height (not just its baseline) varies with horizontal position. Two techniques cooperate to render this, chosen per glyph based on what is available:
Glyph-outline warping (exact). packages/shared/src/render/text-warp-envelope-layout.ts lays every paragraph out unwarped as one block (text-warp-envelope-block.ts) and builds a single continuous mapping over the whole block (createEnvelopeWarp in text-warp-envelope-map.ts, see that module's own doc comment for the P(s, v) = (1 - v) * T(s) + v * B(s) law). When the glyph's actual font FILE is obtainable, text-warp-glyph-outline.ts parses it with opentype.js and maps every point of the glyph's real vector outline (on-curve points and off-curve Bezier control points alike) through that mapping at that POINT'S OWN position, not just the glyph's edges or centre. The glyph then renders as a single warped SVG <path> instead of a <text> element. Unit-tested invariants (text-warp-glyph-outline.test.ts): a "straight-line" envelope reproduces every point unchanged, and a can preset's outline keeps a constant vertical scale across the whole line (the cylinder's top and bottom curves are the SAME radius/sweep, offset by a fixed amount).
A font's actual bytes are obtainable from two sources (text-warp-outline-font-cache.ts): a font EMBEDDED in the deck (bytes already in memory from the load pipeline, parsed synchronously, no network) and a Google Fonts catalogue webfont already resolved for on-screen text (text-warp-outline-webfont-fetch.ts fetches the actual .woff2 bytes separately from the CSS <link>, best-effort). When neither yields a font file, text-warp-glyph-trace.ts traces the outline of the glyph the browser itself renders (an offscreen canvas rasterises the glyph in the exact CSS font a <text> fallback would use, then marching-squares contours the coverage raster at 50%), so a genuinely-unobtainable font FILE no longer falls back to an affine approximation in a browser at all: createGlyphOutlineLookup only reaches the affine/sliced-affine fit (text-warp-glyph-slicing.ts) without a DOM canvas (e.g. server-side rendering). All three paths are wired identically into all five bindings (React, Vue, Angular, Svelte, Vanilla).
Horizontal glyph placement: arc length (COM-remeasured 2026-09-25). createEnvelopeWarp places a glyph's horizontal position by ARC-LENGTH fraction along the preset's top and bottom curves, so a steep stretch of a curve (the vertical ends of the can half-ellipse, the shoulders of a strong deflate) compresses glyphs there and pushes the rest outward. A 2026-09-24 change briefly replaced this with plain linear placement (x = s * width), citing stem positions that were bit-identical between textCanUp adj 15000 and adj 50000; that evidence was void, because textCanUp pins adj to [66667, 100000] (and textCanDown to [0, 33333]), so both values clamp to 66667 and draw the same shape. The whole-outline re-measurement below restored arc length: under linear placement the deepest can shapes scored an ink IoU of only 0.35-0.39, against 0.94-0.95 with arc length. Since outline warping samples every glyph coordinate through this same mapping, a glyph's width and shear fall out of the curve's local slope at its own position, with no per-preset special-casing.
Interior outline error, measured (2026-09-25). Method: scripts/make-wordart-can-fixture.ts writes one black bold WordArt caption per slide (600x285pt box, 40pt, centred) for WORDART and Hamburgefonts in Noto Sans, Verdana and Arial, over textCanUp adj 66667/80000/92000 and textCanDown adj 8000/20000/33333 (all inside the pin ranges), plus textInflate 10000/20000 and textDeflate 18750/37500 controls in Arial, and a separate 20-slide adj sweep of an 8-stem IIIIIIII caption. scripts/measure-wordart-can-com.ps1 exports every slide through PowerPoint COM Slide.Export at 1920x1080 (2.67px per pt, so the box is 1600x760px). scripts/measure-wordart-can-viewer.mjs loads the same deck into the vanilla demo (which resolves pptx-viewer-shared from source) in headless Chromium at device scale 2, screenshots the main slide stage, maps its exact sub-pixel rect onto 1920x1080, and thresholds both images at luminance 128. Metrics per slide: ink IoU (intersection over union of the two masks), and the symmetric contour distance (every boundary pixel of either mask, its Euclidean distance to the nearest boundary pixel of the other; mean and 95th percentile, in px at 1920px slide width). Numbers are the mean over the two strings.
| Case (1920px wide) | IoU linear -> arc | mean px linear -> arc | p95 px linear -> arc |
|---|---|---|---|
can deepest (Up 66667 / Down 33333), 3 fonts | 0.35-0.39 -> 0.94-0.95 | 11.4-11.8 -> 1.1-1.2 | 29-30 -> 2.6-3.0 |
can shallow (Up 92000 / Down 8000), 3 fonts | 0.86-0.88 -> 0.95-0.97 | 2.9-3.4 -> 0.8-1.2 | 6.5 -> 1.5-2.5 |
can middle (Up 80000 / Down 20000), 3 fonts | 0.64-0.67 -> 0.75-0.77 | 8.5-9.0 -> 5.8 | 19 -> 17-18 |
textInflate 10000 / 20000 (Arial) | 0.96 / 0.84 -> 0.97 / 0.97 | 1.0 / 3.7 -> 0.6 / 0.6 | 2.5 / 7.4 -> 1.5 / 1.5 |
textDeflate 18750 / 37500 (Arial) | 0.86 / 0.57 -> 0.97 / 0.96 | 3.2 / 9.0 -> 0.6 / 0.7 | 6.5 / 19 -> 1.5 / 1.5 |
8-stem sweep, 11 of 20 adj values (see below) | 0.59-0.97 -> 0.96-0.97 | 1.6-22.4 -> 1.4-1.7 | 4-48 -> 4-6 |
The middle-adj rows are limited by a PowerPoint artifact, not by the law. The stem sweep shows it directly: for 9 of the 20 swept adj values (textCanUp 80000/83333/86667/93333, textCanDown 3333/6667/13333/20000/23333) PowerPoint ends the text on one of the two paths (top, bottom, or both) short of that path's end, so the caption finishes about 20px (roughly 1.2% of the box width) before the box's right edge on that row, and when only one row is short every glyph leans, by up to about 25px at the right end. The effect is not monotonic in adj (80000 shortens the bottom row, 83333 both, 86667 the top, 90000 neither), which points at PowerPoint's internal curve flattening rather than any closed-form law, so it is left unmodelled; those 9 cases score IoU 0.73-0.86 under both laws. The remaining 11 sweep values, where PowerPoint runs both rows to the path ends, score IoU 0.964-0.971 with mean contour error 1.4-1.7px and p95 4-6px, with no rigid offset beyond 1px (the best integer shift adds at most 0.02 IoU).
A multi-paragraph envelope block's rows can no longer invert order: layoutEnvelopeBlock lays every paragraph out against the SAME block box and does not stretch a short line to fill the box width (a short line keeps its own natural width, centred/aligned within the block), so createEnvelopeWarp's single continuous, row-independent mapping interpolates v (vertical fraction) monotonically for every row without the previous per-row band-slicing that could compute overlapping bands for a very short, heavily-stretched paragraph. text-warp-envelope-layout.test.ts pins this with an adversarial short-vs-long paragraph pair (no row-crossing at any shared horizontal position); the former edgeBandAt/text-warp-glyph-matrix.ts module this replaced, and its 58.7px/48.8px partial-fix measurement, no longer exist.
Measurement provenance
textCanUp/textCanDown's cylindrical top and bottom arcTo curves are parallel (same radius and centre, offset by a constant), so every glyph keeps the same height while still shearing along the arc (COM-verified); inflate/deflate and their variants genuinely vary glyph height instead. Cross-binding agreement is within ~1.5px in e2e/text-warp-envelope-parity.spec.ts for the single-paragraph and wide-glyph-can slicing cases; the multi-paragraph band-ordering case above is not yet green. wide-glyph-can's earlier vanilla-vs-other-bindings slice-count mismatch (4 vanilla slices vs. 5 elsewhere) is fixed: vanilla's WordArt renderer was the one binding skipping the shared PANOSE font-substitution step before measuring glyph advances, which shifted which curve position a glyph's slices sampled, a real per-binding divergence rather than a measurement race.
Traced outlines validated against font files (2026-09-25). The same measurement drives both outline sources over identical glyphs: the plain deck (Noto Sans, Verdana and Arial are all installed locally, so the viewer fetches no webfont and traces every glyph from the browser's own rendering) and a copy re-saved by PowerPoint with "embed fonts" on (scripts/embed-wordart-can-fonts-com.ps1; PowerPoint embeds Noto Sans and Verdana but not Arial), where the viewer parses the embedded font file instead. Against PowerPoint the two paths score the same to within 0.002 IoU and 0.05px mean contour distance in every Noto Sans and Verdana case (for example Verdana textCanUp 66667: 0.952 traced, 0.954 font file), so the 50% marching-squares trace carries no measurable threshold or baseline bias at slide scale. One observation from the same runs: in the embedded deck the first slide painted kept its traced outline (its path data matched the plain deck exactly) while every later slide used the font file; the two agree so closely that it is not visible, but it suggests an embedded-font registration that lands after the first render does not re-render that slide.
Related reading
- Limitations - what is still an open, unresolved gap.
- OpenXML conformance - the package-level (not visual) coverage manifest.