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/grpfill-inheritance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@textcortex/slidewise": patch
---

fix(pptx): render `<a:grpFill/>` shapes by inheriting the group's fill

Decorative line-art (e.g. the swoosh graphic on a title slide) is often
authored as many `<a:custGeom>` segments inside a single `<p:grpSp>`, with every
segment declaring `<a:grpFill/>` so they share the group's one translucent
colour. `parsePptx` had no `grpFill` branch, so each segment fell through to
`transparent` and the entire graphic disappeared on import — present in the
source but invisible in the deck.

The group's resolved fill is now threaded down to descendants, and a shape with
`<a:grpFill/>` paints with it (walking up the group chain when an inner group
defines no fill of its own). Geometry was already preserved; only the paint was
being dropped.
77 changes: 77 additions & 0 deletions packages/slidewise/src/lib/pptx/__tests__/grpfill.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect } from "vitest";
import JSZip from "jszip";
import { parsePptx } from "../index";
import type { SlideElement } from "@/lib/types";

/**
* `<a:grpFill/>` means "paint with the enclosing group's fill". Decorative
* line-art (e.g. the swoosh graphic on a title slide) is commonly authored as
* many `<a:custGeom>` segments inside one `<p:grpSp>`, with every segment
* declaring `<a:grpFill/>` so they share the group's single translucent
* colour. If the importer doesn't resolve grpFill the segments fall through to
* transparent and the whole graphic disappears. This guards that inheritance.
*/
function baseZip(): JSZip {
const zip = new JSZip();
zip.file(
"[Content_Types].xml",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
</Types>`
);
zip.file(
"_rels/.rels",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>`
);
zip.file(
"ppt/presentation.xml",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst><p:sldSz cx="12192000" cy="6858000"/></p:presentation>`
);
zip.file(
"ppt/_rels/presentation.xml.rels",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>`
);
zip.file(
"ppt/slides/_rels/slide1.xml.rels",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
);
return zip;
}

function flatten(els: SlideElement[]): SlideElement[] {
return els.flatMap((e) =>
e.type === "group" ? [e, ...flatten(e.children)] : [e]
);
}

const NS =
`xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"`;

describe("grpFill inheritance", () => {
it("a <a:grpFill/> shape inherits its group's solid fill", async () => {
const zip = baseZip();
// Group carries a red solid fill; its custGeom child declares <a:grpFill/>
// and no fill of its own, so it must render red.
zip.file(
"ppt/slides/slide1.xml",
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sld ${NS}><p:cSld><p:spTree><p:grpSp><p:nvGrpSpPr><p:cNvPr id="2" name="g"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2000000" cy="2000000"/><a:chOff x="0" y="0"/><a:chExt cx="2000000" cy="2000000"/></a:xfrm><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></p:grpSpPr><p:sp><p:nvSpPr><p:cNvPr id="3" name="seg"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1000000" cy="1000000"/></a:xfrm><a:custGeom><a:avLst/><a:gdLst/><a:rect l="0" t="0" r="0" b="0"/><a:pathLst><a:path w="1000000" h="1000000"><a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:lnTo><a:pt x="1000000" y="0"/></a:lnTo><a:lnTo><a:pt x="1000000" y="1000000"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom><a:grpFill/><a:ln><a:noFill/></a:ln></p:spPr></p:sp></p:grpSp></p:spTree></p:cSld></p:sld>`
);

const buffer = await zip.generateAsync({ type: "arraybuffer" });
const deck = await parsePptx(buffer);
const shape = flatten(deck.slides[0].elements).find(
(e) => e.type === "shape"
);
expect(shape).toBeTruthy();
if (shape && shape.type === "shape") {
// Inherited the group's red fill rather than collapsing to transparent.
expect(String(shape.fill).toUpperCase()).toContain("FF0000");
// The custGeom silhouette is preserved so it draws as line-art, not a box.
expect(shape.path).toBeTruthy();
}
});
});
22 changes: 21 additions & 1 deletion packages/slidewise/src/lib/pptx/pptxToDeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,13 @@ interface GroupTransform {
b: number;
c: number;
d: number;
/**
* The enclosing `<p:grpSp>`'s resolved fill, threaded down so descendant
* shapes that declare `<a:grpFill/>` ("inherit my fill from the group") can
* paint with it. Undefined at the slide root and inside groups that define
* no fill of their own. See the `p:grpSp` branch in {@link parseSpTree}.
*/
groupFill?: string;
}

function identityTransform(): GroupTransform {
Expand Down Expand Up @@ -1046,7 +1053,13 @@ async function parseSpTree(
}
} else if (tag === "p:grpSp") {
const inner = composeGroupTransform(node, outer);
const children = await parseSpTree(node, ctx, inner);
// Resolve this group's own fill so descendant shapes that use
// `<a:grpFill/>` (inherit-from-group) can paint with it. A group that
// omits a fill inherits the enclosing group's, matching PowerPoint's
// walk up the group chain.
const groupFill =
extractShapeFill(node?.["p:grpSpPr"], ctx.theme) ?? outer.groupFill;
const children = await parseSpTree(node, ctx, { ...inner, groupFill });
if (!children.length) continue;
const group = buildGroupElement(node, children, ctx, outer);
// Register the whole `<p:grpSp>` so an unedited group round-trips
Expand Down Expand Up @@ -1367,9 +1380,16 @@ async function parseSpOrText(
// it carries the actual art. Resolved to a url("data:…") the renderer
// paints into the shape (clipped to its custGeom path when present).
const blipFill = await extractShapeBlipFill(spPr, ctx);
// `<a:grpFill/>` means "paint with the enclosing group's fill". The group's
// resolved fill is threaded in via `outer.groupFill`; without this the shape
// falls through to transparent and disappears (e.g. decorative custGeom
// line-art whose every segment inherits one translucent group colour).
const grpFill =
spPr?.["a:grpFill"] !== undefined ? outer.groupFill : undefined;
const fillColor =
blipFill
?? extractShapeFill(spPr, ctx.theme)
?? grpFill
?? (phSpPr ? extractShapeFill(phSpPr, ctx.theme) : undefined)
?? resolveStyleFillRef(sp, ctx)
?? "transparent";
Expand Down
Loading