From 151a595ee068103abb1b41c877e6356156a31c7a Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sun, 13 Sep 2026 17:07:42 +0900 Subject: [PATCH] fix(preview): render translucent solid fills and outlines `` / `` / `` on a solid fill or outline color were parsed but never surfaced, so a semi-transparent shape laid over layout artwork rendered as an opaque block that hid the gradient (or slide background) beneath it. OOXML keeps alpha beside the color rather than inside it, so the resolved `#RRGGBB` stays as is and the opacity is exposed through new readers (`resolveDrawingColorOpacity`, `getShapeFillOpacity`, `getShapeStrokeOpacity`); the preview renderer emits `fill-opacity` / `stroke-opacity` from them, including for the custom-geometry path, which previously dropped extra attributes when a path had no stroke. --- .changeset/solid-fill-alpha.md | 17 ++++++ packages/preview/package.json | 2 +- packages/preview/src/render-slide.ts | 35 +++++++++--- site/src/routes/api/+page.svelte | 2 + src/api/fn/shape-color.ts | 27 ++++++++- src/api/fn/shape-read-paint.ts | 73 +++++++++++++++++------- src/api/index.ts | 3 + test/fn-resolve-drawing-color.test.ts | 39 ++++++++++++- test/fn-shape-fill-opacity.test.ts | 81 +++++++++++++++++++++++++++ test/preview-effects-fills.test.ts | 65 +++++++++++++++++++++ 10 files changed, 311 insertions(+), 33 deletions(-) create mode 100644 .changeset/solid-fill-alpha.md create mode 100644 test/fn-shape-fill-opacity.test.ts diff --git a/.changeset/solid-fill-alpha.md b/.changeset/solid-fill-alpha.md new file mode 100644 index 000000000..c2d9bbe8b --- /dev/null +++ b/.changeset/solid-fill-alpha.md @@ -0,0 +1,17 @@ +--- +'@office-kit/pptx': minor +'@office-kit/pptx-preview': patch +--- + +Read and render the transparency of solid fills and outlines. + +`@office-kit/pptx` adds `getShapeFillOpacity`, `getShapeStrokeOpacity`, and +`resolveDrawingColorOpacity`, which resolve `` / `` / +`` on a color to a 0–1 opacity. `getShapeFillColorResolved` and +`getShapeStrokeColorResolved` keep returning the plain `#RRGGBB`. + +`@office-kit/pptx-preview` now emits `fill-opacity` / `stroke-opacity` for +translucent solid fills and outlines, including artwork inherited from slide +layouts and masters. A semi-transparent shape layered over a gradient or the +slide background previously rendered as an opaque block that hid whatever sat +beneath it. diff --git a/packages/preview/package.json b/packages/preview/package.json index 732bc5f68..a756cef8f 100644 --- a/packages/preview/package.json +++ b/packages/preview/package.json @@ -55,7 +55,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "@office-kit/pptx": ">=0.6.3" + "@office-kit/pptx": ">=0.13.0" }, "engines": { "node": ">=22.18.0" diff --git a/packages/preview/src/render-slide.ts b/packages/preview/src/render-slide.ts index c7c02460f..137c533c2 100644 --- a/packages/preview/src/render-slide.ts +++ b/packages/preview/src/render-slide.ts @@ -38,6 +38,7 @@ import { getShapeBoundsResolved, getShapeEffectsEffective, getShapeFillEffective, + getShapeFillOpacity, getShapeFillColorResolved, getShapeFlip, getShapeGradientFill, @@ -86,6 +87,7 @@ import { getShapeStrokeCompound, getShapeStrokeDash, getShapeStrokeJoin, + getShapeStrokeOpacity, getShapeTextAnchor, getShapeTextAutoFitParams, getShapeTextMargins, @@ -739,6 +741,8 @@ const patternDef = (pat: { interface PaintResult { fill: string; + /** Pre-built `fill-opacity` attribute for a translucent solid fill, else ''. */ + fillAttrs: string; stroke: string; strokeWidth: number; /** Extra SVG `` the caller should emit before the shape. */ @@ -812,6 +816,7 @@ const paint = ( pres?: PresentationData, ): PaintResult => { let fillColor: string; + let fillAttrs = ''; let defs = ''; if (fill.kind === 'solid') { // Prefer the transform-aware reader when we have a presentation @@ -822,6 +827,10 @@ const paint = ( let resolved: string | null = null; if (shape && pres) resolved = getShapeFillColorResolved(pres, shape); fillColor = resolved ?? resolveColor(fill.color, theme, '#E5E7EB'); + // `` lives beside the color, not in it — a 27% "veil" over + // layout artwork must not paint as an opaque block. + const opacity = shape ? getShapeFillOpacity(shape) : null; + if (opacity !== null && opacity < 1) fillAttrs = ` fill-opacity="${opacity.toFixed(3)}"`; } else if (fill.kind === 'none') { fillColor = 'none'; } else if (fill.kind === 'gradient') { @@ -879,6 +888,10 @@ const paint = ( strokeColor = resolved ?? resolveColor(stroke.color, theme, '#9CA3AF'); strokeWidth = stroke.widthEmu ?? 9_525; // 1pt if (shape) { + const opacity = getShapeStrokeOpacity(shape); + if (opacity !== null && opacity < 1) { + strokeAttrParts.push(`stroke-opacity="${opacity.toFixed(3)}"`); + } const dash = getShapeStrokeDash(shape); if (dash && dash !== 'solid') { const pattern = DASH_PATTERNS[dash]; @@ -928,6 +941,7 @@ const paint = ( } return { fill: fillColor, + fillAttrs, stroke: strokeColor, strokeWidth, defs, @@ -5808,6 +5822,7 @@ const customGeometryToSvg = ( w: number, h: number, fill: string, + fillExtra: string, stroke: string, strokeWidthEmu: number, strokeExtra: string, @@ -5877,10 +5892,13 @@ const customGeometryToSvg = ( } if (d.length === 0) continue; const pathFill = path.fill === 'none' ? 'none' : fill; + const fillAttrs = path.fill === 'none' ? '' : fillExtra; const strokeAttrs = path.stroke ? ` stroke="${stroke}" stroke-width="${E(strokeWidthEmu)}"${strokeExtra}${markerExtra}` : ' stroke="none"'; - out.push(``); + out.push( + ``, + ); } return out.join(''); }; @@ -6155,6 +6173,7 @@ const renderShape = ( const rawPreset = getShapePreset(shape); const preset = rawPreset ?? 'rect'; + const fa = p.fillAttrs; const sa = p.strokeAttrs ? ` ${p.strokeAttrs}` : ''; const ma = p.markerAttrs ?? ''; // Custom geometry () overrides the preset path entirely. @@ -6162,7 +6181,7 @@ const renderShape = ( const customGeom = rawPreset === null ? getShapeCustomGeometry(shape) : null; let geomSvg = customGeom !== null - ? customGeometryToSvg(customGeom, x, y, w, h, p.fill, p.stroke, p.strokeWidth, sa, ma) + ? customGeometryToSvg(customGeom, x, y, w, h, p.fill, fa, p.stroke, p.strokeWidth, sa, ma) : ''; // No preset and no rendered custom geometry means either a custGeom that // failed to evaluate (a true fallback — marked) or no geometry at all @@ -6176,7 +6195,7 @@ const renderShape = ( if (geomSvg !== '') { // geomSvg already holds the rendered custom geometry. } else if (preset === 'rect') { - geomSvg = ``; + geomSvg = ``; } else if (preset === 'roundRect') { // A6 — adjust-handle aware corner radius. in [0, 50000] = ratio of corner-radius to @@ -6185,28 +6204,28 @@ const renderShape = ( const adjVal = adjusts.adj ?? 16667; const ratio = Math.max(0, Math.min(0.5, adjVal / 100_000)); const r = E(Math.min(w, h) * ratio); - geomSvg = ``; + geomSvg = ``; } else if (preset === 'ellipse' || preset === 'oval') { - geomSvg = ``; + geomSvg = ``; } else { const pathFn = PRESET_PATHS[preset]; if (pathFn) { // The path generators output CSS-px coords directly (post-E). const d = pathFn(x / EMU_PER_PX, y / EMU_PER_PX, w / EMU_PER_PX, h / EMU_PER_PX); - geomSvg = ``; + geomSvg = ``; } else { const pointsFn = PRESET_POINTS[preset]; if (pointsFn) { const points = pointsFn(w / EMU_PER_PX, h / EMU_PER_PX) .map(([nx, ny]) => `${E(x + nx * w)},${E(y + ny * h)}`) .join(' '); - geomSvg = ``; + geomSvg = ``; } else { // Unrecognised preset — fall back to a rectangle, but tag it // with the preset name so users (and future-us) can see which // shape needs a renderer. The `` shows on hover; the // `data-pptx-preset` attribute is for DevTools inspection. - geomSvg = `<rect x="${E(x)}" y="${E(y)}" width="${E(w)}" height="${E(h)}" fill="${p.fill}" stroke="${p.stroke}" stroke-width="${E(p.strokeWidth)}"${sa}${ma} data-pptx-preset="${escapeXml(preset)}"><title>${escapeXml(`preset: ${preset}`)}`; + geomSvg = `${escapeXml(`preset: ${preset}`)}`; } } } diff --git a/site/src/routes/api/+page.svelte b/site/src/routes/api/+page.svelte index 7c9a8dfb0..c162a8e0c 100644 --- a/site/src/routes/api/+page.svelte +++ b/site/src/routes/api/+page.svelte @@ -201,6 +201,7 @@ { name: 'setShapeNoFill' }, { name: 'clearShapeFill' }, { name: 'getShapeFillColor' }, + { name: 'getShapeFillOpacity' }, { name: 'getShapeStroke' }, { name: 'setShapeStroke' }, { name: 'setShapeStrokeDash' }, @@ -208,6 +209,7 @@ { name: 'setShapeNoStroke' }, { name: 'clearShapeStroke' }, { name: 'getShapeStrokeColor' }, + { name: 'getShapeStrokeOpacity' }, { name: 'getShapeStrokeWidth' }, { name: 'getShapeStrokeDash' }, { name: 'getShapeStrokeArrow' }, diff --git a/src/api/fn/shape-color.ts b/src/api/fn/shape-color.ts index 033c557e4..a22a2b94b 100644 --- a/src/api/fn/shape-color.ts +++ b/src/api/fn/shape-color.ts @@ -189,8 +189,8 @@ const applyColorTransforms = (hex: string, transforms: readonly ColorTransformOp [r, g, b] = hslToRgb(newH, s, l); break; } - // alpha / alphaMod / alphaOff intentionally don't touch RGB — they - // surface as `fill-opacity`, not as a tinted color. + // alpha / alphaMod / alphaOff intentionally don't touch RGB — + // `resolveDrawingColorOpacity` surfaces them as an opacity instead. } } return rgb01ToHex(r, g, b); @@ -280,6 +280,29 @@ export const resolveDrawingColor = ( return applyColorTransforms(baseHex, parseColorTransforms(colorEl)); }; +/** + * Resolves the opacity a DrawingML color element carries through its + * `` / `` / `` children (ECMA-376 + * §20.1.2.3.1–3) to a `0`–`1` fraction, applied in document order from a + * fully opaque base. Returns `null` when the element has no alpha + * transform — PowerPoint paints that opaque, but callers can still tell + * "unspecified" apart from an explicit `1`. + * + * Kept separate from `resolveDrawingColor` because alpha never changes the + * `#RRGGBB`; OOXML encodes the two independently and renderers emit the + * opacity next to the color (`fill-opacity` / `stroke-opacity`). + */ +export const resolveDrawingColorOpacity = (colorEl: XmlElement): number | null => { + if (colorEl.name.namespaceURI !== NS.dml) return null; + let opacity: number | null = null; + for (const t of parseColorTransforms(colorEl)) { + if (t.kind === 'alpha') opacity = t.val; + else if (t.kind === 'alphaMod') opacity = (opacity ?? 1) * t.val; + else if (t.kind === 'alphaOff') opacity = (opacity ?? 1) + t.val; + } + return opacity === null ? null : Math.max(0, Math.min(1, opacity)); +}; + // Reads any element shaped like `CT_TextCharacterProperties` (the schema // shared by ``, ``, and ``) into a partial // TextFormat. Used by both the literal-only `getShapeRunFormat` and the diff --git a/src/api/fn/shape-read-paint.ts b/src/api/fn/shape-read-paint.ts index 21c269533..48c4d3ee3 100644 --- a/src/api/fn/shape-read-paint.ts +++ b/src/api/fn/shape-read-paint.ts @@ -1,6 +1,6 @@ // Shape reads: fill and stroke. -import { resolveDrawingColor } from './shape-color.ts'; +import { resolveDrawingColor, resolveDrawingColorOpacity } from './shape-color.ts'; import { getShapePlaceholderIdx, getShapePlaceholderType } from './shape-read-base.ts'; import { getSlideLayout } from './shape-slide-read.ts'; import { partName, resolveTarget } from '../../internal/opc/index.ts'; @@ -44,6 +44,28 @@ export type ShapeStroke = | { readonly kind: 'none' } | { readonly kind: 'inherit' }; +// The DrawingML color element inside `` under `container`, +// or null when there is no solid fill there. +const solidFillColorElement = (container: XmlElement): XmlElement | null => { + const solid = firstChildElement(container, qname('a', 'solidFill', NS.dml)); + if (!solid) return null; + for (const inner of solid.children) { + if (inner.kind === 'element' && inner.name.namespaceURI === NS.dml) return inner; + } + return null; +}; + +const fillColorElement = (shape: SlideShapeData): XmlElement | null => { + const spPr = firstChildElement(shape[SHAPE_ELEMENT], qname('p', 'spPr', NS.pml)); + return spPr ? solidFillColorElement(spPr) : null; +}; + +const strokeColorElement = (shape: SlideShapeData): XmlElement | null => { + const spPr = firstChildElement(shape[SHAPE_ELEMENT], qname('p', 'spPr', NS.pml)); + const ln = spPr ? firstChildElement(spPr, qname('a', 'ln', NS.dml)) : null; + return ln ? solidFillColorElement(ln) : null; +}; + /** * Convenience over `getShapeStroke(shape)`: returns the solid- * stroke color (`#RRGGBB` / `scheme:`) or `null` when the @@ -80,17 +102,20 @@ export const getShapeStrokeColorResolved = ( pres: PresentationData, shape: SlideShapeData, ): string | null => { - const spPr = firstChildElement(shape[SHAPE_ELEMENT], qname('p', 'spPr', NS.pml)); - if (!spPr) return null; - const ln = firstChildElement(spPr, qname('a', 'ln', NS.dml)); - if (!ln) return null; - const solid = firstChildElement(ln, qname('a', 'solidFill', NS.dml)); - if (!solid) return null; - for (const inner of solid.children) { - if (inner.kind !== 'element' || inner.name.namespaceURI !== NS.dml) continue; - return resolveDrawingColor(inner, getPresentationTheme(pres)); - } - return null; + const color = strokeColorElement(shape); + return color ? resolveDrawingColor(color, getPresentationTheme(pres)) : null; +}; + +/** + * Returns the opacity (`0`–`1`) of the shape's own solid outline, read from + * the `` / `` / `` children of its color + * element, or `null` when the outline isn't a solid color or carries no + * alpha transform (PowerPoint draws it fully opaque). Companion to + * `getShapeStrokeColorResolved`, which never carries the alpha channel. + */ +export const getShapeStrokeOpacity = (shape: SlideShapeData): number | null => { + const color = strokeColorElement(shape); + return color ? resolveDrawingColorOpacity(color) : null; }; /** @@ -310,15 +335,21 @@ export const getShapeFillColorResolved = ( pres: PresentationData, shape: SlideShapeData, ): string | null => { - const spPr = firstChildElement(shape[SHAPE_ELEMENT], qname('p', 'spPr', NS.pml)); - if (!spPr) return null; - const solid = firstChildElement(spPr, qname('a', 'solidFill', NS.dml)); - if (!solid) return null; - for (const inner of solid.children) { - if (inner.kind !== 'element' || inner.name.namespaceURI !== NS.dml) continue; - return resolveDrawingColor(inner, getPresentationTheme(pres)); - } - return null; + const color = fillColorElement(shape); + return color ? resolveDrawingColor(color, getPresentationTheme(pres)) : null; +}; + +/** + * Returns the opacity (`0`–`1`) of the shape's own solid fill, read from + * the `` / `` / `` children of its color + * element, or `null` when the fill isn't solid or carries no alpha + * transform (PowerPoint paints it fully opaque). Companion to + * `getShapeFillColorResolved`, which never carries the alpha channel — + * OOXML encodes color and alpha independently. + */ +export const getShapeFillOpacity = (shape: SlideShapeData): number | null => { + const color = fillColorElement(shape); + return color ? resolveDrawingColorOpacity(color) : null; }; /** diff --git a/src/api/index.ts b/src/api/index.ts index 96da5cb2d..f9ba5fcb5 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -234,6 +234,7 @@ export { getShapeGradientFillEffective, getShapeFillColor, getShapeFillColorResolved, + getShapeFillOpacity, getShapeFlip, getShapeHyperlink, getShapeHyperlinkTooltip, @@ -305,6 +306,7 @@ export { getShapeStrokeCompound, getShapeStrokeDash, getShapeStrokeJoin, + getShapeStrokeOpacity, getShapeStrokeWidth, getShapeText, getShapeTextAnchor, @@ -432,6 +434,7 @@ export { replaceHyperlink, resolveDeckBodyTextColor, resolveDrawingColor, + resolveDrawingColorOpacity, replaceTextInNotes, replaceTextInPresentation, replaceTextInSlide, diff --git a/test/fn-resolve-drawing-color.test.ts b/test/fn-resolve-drawing-color.test.ts index 204a4cd10..5bd37e17c 100644 --- a/test/fn-resolve-drawing-color.test.ts +++ b/test/fn-resolve-drawing-color.test.ts @@ -5,7 +5,7 @@ // fill-format readers. import { describe, expect, it } from 'vitest'; -import { resolveDrawingColor } from '../src/api/index.ts'; +import { resolveDrawingColor, resolveDrawingColorOpacity } from '../src/api/index.ts'; import { parseXml } from '../src/internal/xml/index.ts'; const parseColorEl = (xml: string) => parseXml(xml).root; @@ -115,3 +115,40 @@ describe('fn API: resolveDrawingColor', () => { expect(resolveDrawingColor(el, null)).toBe('#123456'); }); }); + +describe('fn API: resolveDrawingColorOpacity', () => { + const A = 'http://schemas.openxmlformats.org/drawingml/2006/main'; + + it('returns null when the color carries no alpha transform', () => { + const el = parseColorEl( + ``, + ); + expect(resolveDrawingColorOpacity(el)).toBeNull(); + }); + + it('reads as a percentage and leaves the resolved color untouched', () => { + const el = parseColorEl( + ``, + ); + expect(resolveDrawingColorOpacity(el)).toBeCloseTo(0.27, 6); + expect(resolveDrawingColor(el, null)).toBe('#3366CC'); + }); + + it('applies alphaMod and alphaOff in document order from an opaque base', () => { + const el = parseColorEl( + ``, + ); + expect(resolveDrawingColorOpacity(el)).toBeCloseTo(0.4, 6); + }); + + it('clamps the result to the 0–1 range and accepts bare-float values', () => { + const over = parseColorEl( + ``, + ); + expect(resolveDrawingColorOpacity(over)).toBe(1); + const bare = parseColorEl( + ``, + ); + expect(resolveDrawingColorOpacity(bare)).toBe(0.5); + }); +}); diff --git a/test/fn-shape-fill-opacity.test.ts b/test/fn-shape-fill-opacity.test.ts new file mode 100644 index 000000000..92d4ffee4 --- /dev/null +++ b/test/fn-shape-fill-opacity.test.ts @@ -0,0 +1,81 @@ +// getShapeFillOpacity / getShapeStrokeOpacity — the alpha channel OOXML keeps +// beside the color. No authoring API writes ``, so the element is +// injected at the OPC zip layer, the same way the preview tests do it. + +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + addSlide, + addSlideShape, + findSlideLayout, + getShapeFillColorResolved, + getShapeFillOpacity, + getShapeStrokeColorResolved, + getShapeStrokeOpacity, + getSlides, + getSlideShapes, + inches, + loadPresentation, + savePresentation, + setShapeFill, + setShapeStroke, +} from '../src/api/index.ts'; +import { readZip, writeZip } from '../src/internal/opc/index.ts'; + +const fixturePath = fileURLToPath(new URL('./fixtures/minimal/blank.pptx', import.meta.url)); +const dec = new TextDecoder(); +const enc = new TextEncoder(); + +// Adds one rect (fill #3366CC, 1pt #000000 outline) to a fresh slide, then +// rewrites the saved slide XML so both colors carry the given alpha children. +const loadRectWithAlpha = async (fillAlpha: string, strokeAlpha: string) => { + const pres = await loadPresentation(await readFile(fixturePath)); + const layout = findSlideLayout(pres, 'Blank'); + if (!layout) throw new Error('Blank layout missing'); + const slide = addSlide(pres, { layout }); + const rect = addSlideShape(slide, { + preset: 'rect', + x: inches(1), + y: inches(1), + w: inches(2), + h: inches(2), + }); + setShapeFill(rect, '#3366CC'); + setShapeStroke(rect, { color: '#000000', widthEmu: 12_700 }); + const { entries } = readZip(await savePresentation(pres)); + const modified = entries.map((e) => { + if (!/slides\/slide\d+\.xml$/.test(e.name)) return e; + const xml = dec + .decode(e.data) + .replace('', `${fillAlpha}`) + .replace('', `${strokeAlpha}`); + return { name: e.name, data: enc.encode(xml) }; + }); + const reloaded = await loadPresentation(writeZip(modified)); + const last = getSlides(reloaded).at(-1); + if (!last) throw new Error('slide missing'); + const shape = getSlideShapes(last).at(-1); + if (!shape) throw new Error('rect missing'); + return { pres: reloaded, shape }; +}; + +describe('fn API: getShapeFillOpacity / getShapeStrokeOpacity', () => { + it('returns null for opaque fills and outlines', async () => { + const { shape } = await loadRectWithAlpha('', ''); + expect(getShapeFillOpacity(shape)).toBeNull(); + expect(getShapeStrokeOpacity(shape)).toBeNull(); + }); + + it('reads the alpha of the fill and the outline independently', async () => { + const { pres, shape } = await loadRectWithAlpha( + '', + '', + ); + expect(getShapeFillOpacity(shape)).toBeCloseTo(0.27, 6); + expect(getShapeStrokeOpacity(shape)).toBeCloseTo(0.5, 6); + // The color readers stay alpha-free: OOXML encodes the two separately. + expect(getShapeFillColorResolved(pres, shape)).toBe('#3366CC'); + expect(getShapeStrokeColorResolved(pres, shape)).toBe('#000000'); + }); +}); diff --git a/test/preview-effects-fills.test.ts b/test/preview-effects-fills.test.ts index 4bdbef3cd..28101e2c6 100644 --- a/test/preview-effects-fills.test.ts +++ b/test/preview-effects-fills.test.ts @@ -22,6 +22,7 @@ import { loadPresentation, savePresentation, setShapeFill, + setShapeStroke, } from '@office-kit/pptx'; import { type ZipEntry, readZip, writeZip } from '../src/internal/opc/index.ts'; import { renderSlideToSvg } from '../packages/preview/src/index.ts'; @@ -219,3 +220,67 @@ describe('renderSlideToSvg: inherited gradient fills', () => { expect(src).not.toContain('FDBA74'); }); }); + +describe('renderSlideToSvg: translucent solid fills and outlines', () => { + // A 27% "veil" laid over layout artwork — common in corporate templates, + // where an opaque render hides the gradient (or slide background) beneath. + const VEIL = + ''; + const VEIL_SHAPE = + '' + + '' + + `${VEIL}`; + + it('emits fill-opacity and stroke-opacity for a slide shape', async () => { + const pres = await loadPresentation(await readFile(fixturePath)); + const layout = findSlideLayout(pres, 'Blank'); + if (!layout) throw new Error('Blank layout missing'); + const slide = addSlide(pres, { layout }); + const rect = addSlideShape(slide, { + preset: 'rect', + x: inches(1), + y: inches(1), + w: inches(2), + h: inches(2), + }); + setShapeFill(rect, '#3366CC'); + setShapeStroke(rect, { color: '#000000', widthEmu: 12_700 }); + const { entries } = readZip(await savePresentation(pres)); + const modified = editEntry(entries, slideEntryName(entries), (xml) => + xml + .replace( + '', + '', + ) + .replace( + '', + '', + ), + ); + const reloaded = await loadPresentation(writeZip(modified)); + const svg = renderSlideToSvg(reloaded, getSlides(reloaded).at(-1)!, { textLayout: 'svg' }); + expect(svg).toContain('fill="#3366CC" fill-opacity="0.270"'); + expect(svg).toContain('stroke-opacity="0.500"'); + }); + + it('keeps layout artwork translucent instead of painting an opaque block', async () => { + const pres = await loadPresentation(await readFile(fixturePath)); + const layout = findSlideLayout(pres, 'Blank'); + if (!layout) throw new Error('Blank layout missing'); + addSlide(pres, { layout }); + const { entries } = readZip(await savePresentation(pres)); + const slideName = slideEntryName(entries); + const rels = entries.find( + (e) => e.name === slideName.replace(/slides\/(slide\d+\.xml)$/, 'slides/_rels/$1.rels'), + ); + if (!rels) throw new Error('slide rels missing'); + const layoutFile = /slideLayouts\/(slideLayout\d+\.xml)/.exec(dec.decode(rels.data))?.[1]; + if (!layoutFile) throw new Error('layout relationship missing'); + const modified = editEntry(entries, `ppt/slideLayouts/${layoutFile}`, (xml) => + xml.replace('', `${VEIL_SHAPE}`), + ); + const reloaded = await loadPresentation(writeZip(modified)); + const svg = renderSlideToSvg(reloaded, getSlides(reloaded).at(-1)!, { textLayout: 'svg' }); + expect(svg).toContain('fill="#3366CC" fill-opacity="0.270"'); + }); +});