Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/solid-fill-alpha.md
Original file line number Diff line number Diff line change
@@ -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 `<a:alpha>` / `<a:alphaMod>` /
`<a:alphaOff>` 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.
2 changes: 1 addition & 1 deletion packages/preview/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
35 changes: 27 additions & 8 deletions packages/preview/src/render-slide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
getShapeBoundsResolved,
getShapeEffectsEffective,
getShapeFillEffective,
getShapeFillOpacity,
getShapeFillColorResolved,
getShapeFlip,
getShapeGradientFill,
Expand Down Expand Up @@ -86,6 +87,7 @@ import {
getShapeStrokeCompound,
getShapeStrokeDash,
getShapeStrokeJoin,
getShapeStrokeOpacity,
getShapeTextAnchor,
getShapeTextAutoFitParams,
getShapeTextMargins,
Expand Down Expand Up @@ -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 `<defs>` the caller should emit before the shape. */
Expand Down Expand Up @@ -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
Expand All @@ -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');
// `<a:alpha>` 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') {
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -928,6 +941,7 @@ const paint = (
}
return {
fill: fillColor,
fillAttrs,
stroke: strokeColor,
strokeWidth,
defs,
Expand Down Expand Up @@ -5808,6 +5822,7 @@ const customGeometryToSvg = (
w: number,
h: number,
fill: string,
fillExtra: string,
stroke: string,
strokeWidthEmu: number,
strokeExtra: string,
Expand Down Expand Up @@ -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(`<path d="${d.join(' ')}" fill="${pathFill}"${strokeAttrs} fill-rule="evenodd"/>`);
out.push(
`<path d="${d.join(' ')}" fill="${pathFill}"${fillAttrs}${strokeAttrs} fill-rule="evenodd"/>`,
);
}
return out.join('');
};
Expand Down Expand Up @@ -6155,14 +6173,15 @@ 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 (<a:custGeom>) overrides the preset path entirely.
// getShapePreset returns null for it, so only the no-preset case probes.
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
Expand All @@ -6176,7 +6195,7 @@ const renderShape = (
if (geomSvg !== '') {
// geomSvg already holds the rendered custom geometry.
} else if (preset === 'rect') {
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}/>`;
geomSvg = `<rect x="${E(x)}" y="${E(y)}" width="${E(w)}" height="${E(h)}" fill="${p.fill}"${fa} stroke="${p.stroke}" stroke-width="${E(p.strokeWidth)}"${sa}${ma}/>`;
} else if (preset === 'roundRect') {
// A6 — adjust-handle aware corner radius. <a:gd name="adj"
// fmla="val N"/> in [0, 50000] = ratio of corner-radius to
Expand All @@ -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 = `<rect x="${E(x)}" y="${E(y)}" width="${E(w)}" height="${E(h)}" rx="${r}" ry="${r}" fill="${p.fill}" stroke="${p.stroke}" stroke-width="${E(p.strokeWidth)}"${sa}${ma}/>`;
geomSvg = `<rect x="${E(x)}" y="${E(y)}" width="${E(w)}" height="${E(h)}" rx="${r}" ry="${r}" fill="${p.fill}"${fa} stroke="${p.stroke}" stroke-width="${E(p.strokeWidth)}"${sa}${ma}/>`;
} else if (preset === 'ellipse' || preset === 'oval') {
geomSvg = `<ellipse cx="${E(cx)}" cy="${E(cy)}" rx="${E(w / 2)}" ry="${E(h / 2)}" fill="${p.fill}" stroke="${p.stroke}" stroke-width="${E(p.strokeWidth)}"${sa}${ma}/>`;
geomSvg = `<ellipse cx="${E(cx)}" cy="${E(cy)}" rx="${E(w / 2)}" ry="${E(h / 2)}" fill="${p.fill}"${fa} stroke="${p.stroke}" stroke-width="${E(p.strokeWidth)}"${sa}${ma}/>`;
} 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 = `<path d="${d}" fill="${p.fill}" stroke="${p.stroke}" stroke-width="${E(p.strokeWidth)}" fill-rule="evenodd"${sa}${ma}/>`;
geomSvg = `<path d="${d}" fill="${p.fill}"${fa} stroke="${p.stroke}" stroke-width="${E(p.strokeWidth)}" fill-rule="evenodd"${sa}${ma}/>`;
} 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 = `<polygon points="${points}" fill="${p.fill}" stroke="${p.stroke}" stroke-width="${E(p.strokeWidth)}"${sa}${ma}/>`;
geomSvg = `<polygon points="${points}" fill="${p.fill}"${fa} stroke="${p.stroke}" stroke-width="${E(p.strokeWidth)}"${sa}${ma}/>`;
} 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 `<title>` 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}`)}</title></rect>`;
geomSvg = `<rect x="${E(x)}" y="${E(y)}" width="${E(w)}" height="${E(h)}" fill="${p.fill}"${fa} stroke="${p.stroke}" stroke-width="${E(p.strokeWidth)}"${sa}${ma} data-pptx-preset="${escapeXml(preset)}"><title>${escapeXml(`preset: ${preset}`)}</title></rect>`;
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions site/src/routes/api/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,15 @@
{ name: 'setShapeNoFill' },
{ name: 'clearShapeFill' },
{ name: 'getShapeFillColor' },
{ name: 'getShapeFillOpacity' },
{ name: 'getShapeStroke' },
{ name: 'setShapeStroke' },
{ name: 'setShapeStrokeDash' },
{ name: 'setShapeStrokeArrow' },
{ name: 'setShapeNoStroke' },
{ name: 'clearShapeStroke' },
{ name: 'getShapeStrokeColor' },
{ name: 'getShapeStrokeOpacity' },
{ name: 'getShapeStrokeWidth' },
{ name: 'getShapeStrokeDash' },
{ name: 'getShapeStrokeArrow' },
Expand Down
27 changes: 25 additions & 2 deletions src/api/fn/shape-color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -280,6 +280,29 @@ export const resolveDrawingColor = (
return applyColorTransforms(baseHex, parseColorTransforms(colorEl));
};

/**
* Resolves the opacity a DrawingML color element carries through its
* `<a:alpha>` / `<a:alphaMod>` / `<a:alphaOff>` 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 `<a:rPr>`, `<a:defRPr>`, and `<a:endParaRPr>`) into a partial
// TextFormat. Used by both the literal-only `getShapeRunFormat` and the
Expand Down
73 changes: 52 additions & 21 deletions src/api/fn/shape-read-paint.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -44,6 +44,28 @@ export type ShapeStroke =
| { readonly kind: 'none' }
| { readonly kind: 'inherit' };

// The DrawingML color element inside `<a:solidFill>` 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:<token>`) or `null` when the
Expand Down Expand Up @@ -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 `<a:alpha>` / `<a:alphaMod>` / `<a:alphaOff>` 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;
};

/**
Expand Down Expand Up @@ -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 `<a:alpha>` / `<a:alphaMod>` / `<a:alphaOff>` 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;
};

/**
Expand Down
3 changes: 3 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export {
getShapeGradientFillEffective,
getShapeFillColor,
getShapeFillColorResolved,
getShapeFillOpacity,
getShapeFlip,
getShapeHyperlink,
getShapeHyperlinkTooltip,
Expand Down Expand Up @@ -305,6 +306,7 @@ export {
getShapeStrokeCompound,
getShapeStrokeDash,
getShapeStrokeJoin,
getShapeStrokeOpacity,
getShapeStrokeWidth,
getShapeText,
getShapeTextAnchor,
Expand Down Expand Up @@ -432,6 +434,7 @@ export {
replaceHyperlink,
resolveDeckBodyTextColor,
resolveDrawingColor,
resolveDrawingColorOpacity,
replaceTextInNotes,
replaceTextInPresentation,
replaceTextInSlide,
Expand Down
Loading
Loading