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"'); + }); +});