diff --git a/.changeset/grpfill-inheritance.md b/.changeset/grpfill-inheritance.md new file mode 100644 index 0000000..39a1e88 --- /dev/null +++ b/.changeset/grpfill-inheritance.md @@ -0,0 +1,17 @@ +--- +"@textcortex/slidewise": patch +--- + +fix(pptx): render `` shapes by inheriting the group's fill + +Decorative line-art (e.g. the swoosh graphic on a title slide) is often +authored as many `` segments inside a single ``, with every +segment declaring `` 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 +`` 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. diff --git a/packages/slidewise/src/lib/pptx/__tests__/grpfill.test.ts b/packages/slidewise/src/lib/pptx/__tests__/grpfill.test.ts new file mode 100644 index 0000000..3316bdd --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/grpfill.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import JSZip from "jszip"; +import { parsePptx } from "../index"; +import type { SlideElement } from "@/lib/types"; + +/** + * `` 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 `` segments inside one ``, with every segment + * declaring `` 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", + ` + + + + + +` + ); + zip.file( + "_rels/.rels", + `` + ); + zip.file( + "ppt/presentation.xml", + `` + ); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + 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 shape inherits its group's solid fill", async () => { + const zip = baseZip(); + // Group carries a red solid fill; its custGeom child declares + // and no fill of its own, so it must render red. + zip.file( + "ppt/slides/slide1.xml", + `` + ); + + 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(); + } + }); +}); diff --git a/packages/slidewise/src/lib/pptx/pptxToDeck.ts b/packages/slidewise/src/lib/pptx/pptxToDeck.ts index 49ffec4..0704557 100644 --- a/packages/slidewise/src/lib/pptx/pptxToDeck.ts +++ b/packages/slidewise/src/lib/pptx/pptxToDeck.ts @@ -975,6 +975,13 @@ interface GroupTransform { b: number; c: number; d: number; + /** + * The enclosing ``'s resolved fill, threaded down so descendant + * shapes that declare `` ("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 { @@ -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 + // `` (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 `` so an unedited group round-trips @@ -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); + // `` 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";