Skip to content

Commit 83683e3

Browse files
fix(pptx): inherit group fill for <a:grpFill/> shapes (#83)
Shapes using <a:grpFill/> now inherit the enclosing group's resolved fill instead of collapsing to transparent, so decorative custGeom line-art that shares one translucent group colour stays visible on import.
1 parent 30e4a06 commit 83683e3

3 files changed

Lines changed: 115 additions & 1 deletion

File tree

.changeset/grpfill-inheritance.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@textcortex/slidewise": patch
3+
---
4+
5+
fix(pptx): render `<a:grpFill/>` shapes by inheriting the group's fill
6+
7+
Decorative line-art (e.g. the swoosh graphic on a title slide) is often
8+
authored as many `<a:custGeom>` segments inside a single `<p:grpSp>`, with every
9+
segment declaring `<a:grpFill/>` so they share the group's one translucent
10+
colour. `parsePptx` had no `grpFill` branch, so each segment fell through to
11+
`transparent` and the entire graphic disappeared on import — present in the
12+
source but invisible in the deck.
13+
14+
The group's resolved fill is now threaded down to descendants, and a shape with
15+
`<a:grpFill/>` paints with it (walking up the group chain when an inner group
16+
defines no fill of its own). Geometry was already preserved; only the paint was
17+
being dropped.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, it, expect } from "vitest";
2+
import JSZip from "jszip";
3+
import { parsePptx } from "../index";
4+
import type { SlideElement } from "@/lib/types";
5+
6+
/**
7+
* `<a:grpFill/>` means "paint with the enclosing group's fill". Decorative
8+
* line-art (e.g. the swoosh graphic on a title slide) is commonly authored as
9+
* many `<a:custGeom>` segments inside one `<p:grpSp>`, with every segment
10+
* declaring `<a:grpFill/>` so they share the group's single translucent
11+
* colour. If the importer doesn't resolve grpFill the segments fall through to
12+
* transparent and the whole graphic disappears. This guards that inheritance.
13+
*/
14+
function baseZip(): JSZip {
15+
const zip = new JSZip();
16+
zip.file(
17+
"[Content_Types].xml",
18+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
19+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
20+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
21+
<Default Extension="xml" ContentType="application/xml"/>
22+
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
23+
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
24+
</Types>`
25+
);
26+
zip.file(
27+
"_rels/.rels",
28+
`<?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>`
29+
);
30+
zip.file(
31+
"ppt/presentation.xml",
32+
`<?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>`
33+
);
34+
zip.file(
35+
"ppt/_rels/presentation.xml.rels",
36+
`<?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>`
37+
);
38+
zip.file(
39+
"ppt/slides/_rels/slide1.xml.rels",
40+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
41+
);
42+
return zip;
43+
}
44+
45+
function flatten(els: SlideElement[]): SlideElement[] {
46+
return els.flatMap((e) =>
47+
e.type === "group" ? [e, ...flatten(e.children)] : [e]
48+
);
49+
}
50+
51+
const NS =
52+
`xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"`;
53+
54+
describe("grpFill inheritance", () => {
55+
it("a <a:grpFill/> shape inherits its group's solid fill", async () => {
56+
const zip = baseZip();
57+
// Group carries a red solid fill; its custGeom child declares <a:grpFill/>
58+
// and no fill of its own, so it must render red.
59+
zip.file(
60+
"ppt/slides/slide1.xml",
61+
`<?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>`
62+
);
63+
64+
const buffer = await zip.generateAsync({ type: "arraybuffer" });
65+
const deck = await parsePptx(buffer);
66+
const shape = flatten(deck.slides[0].elements).find(
67+
(e) => e.type === "shape"
68+
);
69+
expect(shape).toBeTruthy();
70+
if (shape && shape.type === "shape") {
71+
// Inherited the group's red fill rather than collapsing to transparent.
72+
expect(String(shape.fill).toUpperCase()).toContain("FF0000");
73+
// The custGeom silhouette is preserved so it draws as line-art, not a box.
74+
expect(shape.path).toBeTruthy();
75+
}
76+
});
77+
});

packages/slidewise/src/lib/pptx/pptxToDeck.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,13 @@ interface GroupTransform {
975975
b: number;
976976
c: number;
977977
d: number;
978+
/**
979+
* The enclosing `<p:grpSp>`'s resolved fill, threaded down so descendant
980+
* shapes that declare `<a:grpFill/>` ("inherit my fill from the group") can
981+
* paint with it. Undefined at the slide root and inside groups that define
982+
* no fill of their own. See the `p:grpSp` branch in {@link parseSpTree}.
983+
*/
984+
groupFill?: string;
978985
}
979986

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

0 commit comments

Comments
 (0)