diff --git a/.changeset/intero-import-fidelity.md b/.changeset/intero-import-fidelity.md new file mode 100644 index 0000000..a784890 --- /dev/null +++ b/.changeset/intero-import-fidelity.md @@ -0,0 +1,17 @@ +--- +"@textcortex/slidewise": patch +--- + +fix(pptx): import-fidelity fixes for think-cell / brand-template decks + +- Skip shapes flagged `hidden="1"` (e.g. think-cell "do not delete" data objects) +- Render run-level text highlight (``) end to end +- Apply `cap="all"`/`"small"` (including when inherited from a placeholder list style) as a render-time letter-case transform +- Derive font weight from weight-named families ("Gilroy ExtraBold" → 800, "… Medium" → 500, …) so substitute fonts render at the right heaviness +- Tables: per-cell fills, text colours, per-side borders, proportional column widths / row heights, cell spans (`gridSpan`/`hMerge`/`rowSpan`/`vMerge`), per-cell vertical anchor, and rich per-cell runs (highlight / bold / ✓ glyphs / bullet line breaks). Unfilled cells stay transparent instead of inheriting a sibling fill +- Map Wingdings bullet glyphs to Unicode (`ü`→✓, `q`→☐, `§`→▪, …) +- Bullets: repeat a character bullet across in-paragraph line breaks, suppress the glyph on empty paragraphs, and trim trailing empty paragraphs +- Synthesise block-arrow paths (`down`/`up`/`left`/`rightArrow`) and resolve outline colour from `` so dashed/outlined shapes draw +- Keep a text-bearing preset or custom-geometry shape's fill, border, and corner radius behind its text (roundRect callouts, outlined chevrons) +- Honour `` no-wrap for short single-line labels; skip the arrow-tip text inset on no-fill label shapes +- Render per-paragraph hanging-indent bullets as one block per line so multi-line items align correctly diff --git a/packages/slidewise/src/components/editor/ElementView.tsx b/packages/slidewise/src/components/editor/ElementView.tsx index 1a4125b..8b6aebc 100644 --- a/packages/slidewise/src/components/editor/ElementView.tsx +++ b/packages/slidewise/src/components/editor/ElementView.tsx @@ -7,6 +7,7 @@ import type { ImageElement, LineElement, TableElement, + CellBorderSide, IconElement, EmbedElement, ChartElement, @@ -134,10 +135,15 @@ function TextView({ ? "center" : "flex-end", background: el.background, + // Border / radius for a text-bearing preset shape (e.g. roundRect callout). + border: el.borderColor + ? `${el.borderWidth ?? 1}px solid ${el.borderColor}` + : undefined, + borderRadius: el.borderRadius ? el.borderRadius : undefined, padding: el.padding ? `${el.padding.t}px ${el.padding.r}px ${el.padding.b}px ${el.padding.l}px` : undefined, - boxSizing: el.padding ? "border-box" : undefined, + boxSizing: el.padding || el.borderColor ? "border-box" : undefined, cursor: editing ? "text" : "inherit", }; const inner: React.CSSProperties = { @@ -154,8 +160,8 @@ function TextView({ textAlign: el.align, lineHeight: el.lineHeight, letterSpacing: el.letterSpacing, - whiteSpace: "pre-wrap", - wordBreak: "break-word", + whiteSpace: el.noWrap ? "pre" : "pre-wrap", + wordBreak: el.noWrap ? "normal" : "break-word", outline: "none", }; @@ -187,6 +193,9 @@ function TextView({ d={backingPath.d} fill={backingPaint.paint} fillRule={backingPath.fillRule ?? "nonzero"} + stroke={backingPath.stroke} + strokeWidth={backingPath.stroke ? backingPath.strokeWidth ?? 1 : undefined} + vectorEffect={backingPath.stroke ? "non-scaling-stroke" : undefined} /> ) : null; @@ -216,20 +225,38 @@ function TextView({ {backingSvg}
{el.paragraphs.map((pp, pi) => { + // Indent / spacing live on the per-line blocks below; the wrapper + // only carries alignment (inherited by its line children). const paraStyle: React.CSSProperties = { - paddingLeft: pp.marL ? pp.marL : undefined, - textIndent: pp.indent ? pp.indent : undefined, textAlign: pp.align ?? undefined, - marginTop: pp.spaceBefore ? pp.spaceBefore : undefined, }; - const content = + // A hanging-indent paragraph needs each line as its own block — + // CSS text-indent only affects a block's first line, so a + // multi-line bulleted paragraph would misalign every bullet after + // the first. Split on "\n" and render one indented block per line. + const lineRuns: TextRun[][] = pp.runs && pp.runs.length - ? pp.runs.map((r, ri) => ( - - {r.text} - - )) - : pp.text; + ? splitRunsByNewline(pp.runs) + : (pp.text ?? "").split("\n").map((t) => [{ text: t }]); + const content = lineRuns.map((line, li) => ( +
+ {line.some((r) => r.text.length > 0) + ? line.map((r, ri) => ( + + {r.text} + + )) + : /* keep an empty paragraph's line height (blank line) */ " "} +
+ )); return (
{content || " "} @@ -288,13 +315,37 @@ function withGenericFallback(family: string | undefined): string | undefined { return `${family}, sans-serif`; } +/** + * Split a run list into per-line groups at "\n", preserving each run's style. + * Used so a hanging-indent paragraph can render each line as its own block. + */ +function splitRunsByNewline(runs: TextRun[]): TextRun[][] { + const lines: TextRun[][] = [[]]; + for (const r of runs) { + const parts = r.text.split("\n"); + parts.forEach((part, i) => { + if (i > 0) lines.push([]); + if (part.length) lines[lines.length - 1].push({ ...r, text: part }); + }); + } + return lines; +} + function runCssStyle(r: TextRun): React.CSSProperties { const s: React.CSSProperties = {}; if (r.fontFamily) s.fontFamily = withGenericFallback(r.fontFamily); if (r.fontSize) s.fontSize = r.fontSize; if (r.fontWeight) s.fontWeight = r.fontWeight; if (r.color) s.color = r.color; + if (r.highlight) { + s.backgroundColor = r.highlight; + // Keep the highlight painted continuously across wrapped lines. + s.boxDecorationBreak = "clone"; + s.WebkitBoxDecorationBreak = "clone"; + } if (r.italic) s.fontStyle = "italic"; + if (r.cap === "all") s.textTransform = "uppercase"; + else if (r.cap === "small") s.fontVariant = "small-caps"; if (r.letterSpacing != null) s.letterSpacing = r.letterSpacing; const decoration = [r.underline && "underline", r.strike && "line-through"] .filter(Boolean) @@ -389,6 +440,9 @@ function runsToHtml(runs: TextRun[]): string { if (r.fontSize) props.push(`font-size: ${r.fontSize}px`); if (r.fontWeight) props.push(`font-weight: ${r.fontWeight}`); if (r.italic) props.push(`font-style: italic`); + if (r.cap === "all") props.push(`text-transform: uppercase`); + else if (r.cap === "small") props.push(`font-variant: small-caps`); + if (r.highlight) props.push(`background-color: ${r.highlight}`); if (r.letterSpacing != null) props.push(`letter-spacing: ${r.letterSpacing}px`); const decoration = [r.underline && "underline", r.strike && "line-through"] .filter(Boolean) @@ -417,6 +471,9 @@ function styleToRun(el: HTMLElement, text: string): TextRun { if (Number.isFinite(w)) r.fontWeight = w; } if (s.fontStyle === "italic") r.italic = true; + if (s.textTransform === "uppercase") r.cap = "all"; + else if (s.fontVariant === "small-caps") r.cap = "small"; + if (s.backgroundColor) r.highlight = s.backgroundColor; if (s.letterSpacing) { const ls = parseFloat(s.letterSpacing); if (Number.isFinite(ls)) r.letterSpacing = ls; @@ -477,6 +534,8 @@ function sameStyle(a: TextRun, b: TextRun): boolean { a.italic === b.italic && a.underline === b.underline && a.strike === b.strike && + a.highlight === b.highlight && + a.cap === b.cap && a.letterSpacing === b.letterSpacing ); } @@ -965,6 +1024,15 @@ function TableView({ el }: { el: TableElement }) { const hasHeader = el.hasHeader ?? true; const bandRows = el.bandRows ?? false; const cellFill = (ri: number, ci: number): string => { + // An explicit per-cell fill (PPTX override) wins over every + // row-class default — this is what paints think-cell Gantt cells. + const perCell = el.cellFills?.[ri]?.[ci]; + if (perCell) return perCell; + // In a per-cell-fill table, a cell with no fill of its own is transparent + // (the slide shows through). It must NOT fall back to headerFill/rowFill — + // those were derived from some other cell and would flood unfilled cells + // with that colour (e.g. a stray cream band turning the whole grid cream). + if (el.cellFills) return "transparent"; if (hasHeader && ri === 0) return el.headerFill; if (el.lastRowFill && ri === rowCount - 1 && rowCount > 1) return el.lastRowFill; if (el.firstColFill && ci === 0) return el.firstColFill; @@ -978,27 +1046,98 @@ function TableView({ el }: { el: TableElement }) { return el.rowFill; }; const cellColor = (ri: number, ci: number): string => { + const perCell = el.cellTextColors?.[ri]?.[ci]; + if (perCell) return perCell; if (hasHeader && ri === 0 && el.headerTextColor) return el.headerTextColor; if (el.firstColTextColor && ci === 0 && !(hasHeader && ri === 0)) { return el.firstColTextColor; } return el.textColor; }; + + // When the source defined per-cell borders, honour them exactly: most PPTX + // (think-cell) cells leave sides blank, so a uniform grid is wrong. Each + // internal edge is drawn once — by the cell above (its bottom) or to the + // left (its right) — and a coloured side wins over a neighbour's blank one, + // so shared edges never double up. + const hasCellBorders = !!el.cellBorders; + const sideCss = (s: CellBorderSide | null | undefined): string | undefined => + s ? `${s.width}px solid ${s.color}` : undefined; + // Pick the drawn line between two adjacent sides (a colour beats null/absent). + const mergeSide = ( + a: CellBorderSide | null | undefined, + b: CellBorderSide | null | undefined + ): CellBorderSide | null | undefined => a ?? b; + // Merged cells: a covered continuation cell renders nothing, and a spanning + // origin cell is placed explicitly so it covers the columns/rows it merges + // (e.g. a full-width band). Explicit placement (col/row = array index) avoids + // auto-flow ambiguity once some cells span and others are omitted. + const hasSpans = !!el.cellSpans; + const cellPlacement = (ri: number, ci: number): React.CSSProperties => { + if (!hasSpans) return {}; + const span = el.cellSpans?.[ri]?.[ci]; + return { + gridColumn: `${ci + 1} / span ${span?.colSpan ?? 1}`, + gridRow: `${ri + 1} / span ${span?.rowSpan ?? 1}`, + }; + }; + const cellBorderStyle = (ri: number, ci: number): React.CSSProperties => { + if (!hasCellBorders) { + // Legacy default: a single faint grid line shared between cells. + return { + borderRight: ci < cols - 1 ? `1px solid ${stroke}` : undefined, + borderBottom: ri < rowCount - 1 ? `1px solid ${stroke}` : undefined, + }; + } + const cb = el.cellBorders?.[ri]?.[ci] ?? undefined; + const right = mergeSide(cb?.r, el.cellBorders?.[ri]?.[ci + 1]?.l); + const bottom = mergeSide(cb?.b, el.cellBorders?.[ri + 1]?.[ci]?.t); + return { + // Outer top/left edges belong to the first row/column; internal top/left + // edges are covered by the neighbour's bottom/right so they aren't doubled. + borderTop: ri === 0 ? sideCss(cb?.t) : undefined, + borderLeft: ci === 0 ? sideCss(cb?.l) : undefined, + borderRight: sideCss(right), + borderBottom: sideCss(bottom), + }; + }; return (
`${w}fr`).join(" ") + : `repeat(${cols}, 1fr)`, + gridTemplateRows: + el.rowHeights && el.rowHeights.length === rowCount + ? el.rowHeights.map((h) => `${h}fr`).join(" ") + : `repeat(${rowCount}, 1fr)`, width: "100%", height: "100%", gap: 0, background: "transparent", - boxShadow: `inset 0 0 0 1px ${stroke}`, + // The legacy frame only applies to tables without explicit cell borders. + boxShadow: hasCellBorders ? undefined : `inset 0 0 0 1px ${stroke}`, }} > {el.rows.flatMap((row, ri) => - row.map((cell, ci) => ( + row.map((cell, ci) => { + // Cells merged into a neighbour aren't rendered — the spanning + // origin covers their grid slot. + if (el.cellSpans?.[ri]?.[ci]?.covered) return null; + // Rich runs (highlight / per-run font / bullet line breaks / ✓ + // glyphs) take over from the flat string when present. + const runs = el.cellRuns?.[ri]?.[ci]; + const content = + runs && runs.length + ? runs.map((r, i) => ( + + {r.text} + + )) + : cell; + return (
); otherwise fall back to the + // header-centred / body-top default. PPTX cells default to top. + alignItems: (() => { + const va = el.cellVAligns?.[ri]?.[ci]; + if (va === "middle") return "center"; + if (va === "bottom") return "flex-end"; + if (va === "top") return "flex-start"; + return hasHeader && ri === 0 ? "center" : "flex-start"; + })(), fontWeight: (hasHeader && ri === 0) || (el.firstColFill && ci === 0) ? 600 @@ -1017,15 +1165,19 @@ function TableView({ el }: { el: TableElement }) { minHeight: 0, overflow: "hidden", wordBreak: "break-word", - borderRight: - ci < cols - 1 ? `1px solid ${stroke}` : undefined, - borderBottom: - ri < rowCount - 1 ? `1px solid ${stroke}` : undefined, + ...cellPlacement(ri, ci), + ...cellBorderStyle(ri, ci), }} > - {cell} + {/* Single inline-flow child so run spans wrap as text and the + "\n" bullet breaks apply (flex children would lay out in a + row, collapsing every bullet onto one line). */} +
+ {content} +
- )) + ); + }) )}
); diff --git a/packages/slidewise/src/lib/pptx/__tests__/round2.test.ts b/packages/slidewise/src/lib/pptx/__tests__/round2.test.ts index 0d1f005..edeb1b5 100644 --- a/packages/slidewise/src/lib/pptx/__tests__/round2.test.ts +++ b/packages/slidewise/src/lib/pptx/__tests__/round2.test.ts @@ -167,4 +167,601 @@ describe("pptx importer — round 2", () => { const image = deck.slides[0].elements.find((e) => e.type === "image"); expect(image).toBeUndefined(); }); + + it("captures rPr cap='all' as a run-level uppercase transform", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + zip.file( + "ppt/slides/slide1.xml", + `Kapitelname` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const text = deck.slides[0].elements.find((e) => e.type === "text"); + expect(text?.type).toBe("text"); + if (text?.type !== "text") return; + // Stored characters are unchanged; the transform rides on the run. + expect(text.text).toContain("Kapitelname"); + expect(text.runs?.[0]?.cap).toBe("all"); + // A single-line spAutoFit box must not re-wrap under a substitute font. + expect(text.noWrap).toBe(true); + }); + + it("applies a shape's own txBody lstStyle defRPr to runs that omit props", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // Non-placeholder text box: the run has no sz/b/typeface — those live only + // in the shape's own . Without consulting + // it, the run would fall through to the master default (wrong size/font). + zip.file( + "ppt/slides/slide1.xml", + `02 |` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const text = deck.slides[0].elements.find((e) => e.type === "text"); + expect(text?.type).toBe("text"); + if (text?.type !== "text") return; + // Weight + typeface come from the shape's lstStyle defRPr, not the master. + expect(text.fontWeight).toBe(700); + expect(text.fontFamily).toBe("Arial"); + }); + + it("skips the arrow-tip text inset for a no-fill homePlate label", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + const slide = (fill: string) => + `${fill}Phase`; + const parse = async (fill: string) => { + zip.file("ppt/slides/slide1.xml", slide(fill)); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const el = deck.slides[0].elements.find((e) => e.type === "text"); + return el?.type === "text" ? el.padding?.r ?? 0 : -1; + }; + // No fill → no arrow tip reserved → padding.r is just the small rIns. + const noFillR = await parse(""); + // Filled (visible arrow) → text is inset away from the tip → much larger. + const filledR = await parse( + "" + ); + expect(noFillR).toBeLessThan(20); + expect(filledR).toBeGreaterThan(noFillR + 20); + }); + + it("derives font-weight from a weight-named family (Gilroy ExtraBold → 800)", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // Family name encodes the weight; the substitute font must render heavy. + zip.file( + "ppt/slides/slide1.xml", + `Phase` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const text = deck.slides[0].elements.find((e) => e.type === "text"); + if (text?.type !== "text") return; + // No bold attribute, but the "ExtraBold" family name drives weight 800. + expect(text.fontWeight).toBe(800); + }); + + it("keeps distinct per-cell fills and text colours (think-cell Gantt cells)", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // 2×2 table: every cell carries its own . The flat + // header/body model would collapse these to two colours — the per-cell + // arrays must preserve all four. + const cell = (txt: string, hex: string, textHex: string) => + `${txt}`; + zip.file( + "ppt/slides/slide1.xml", + `${cell("A", "3F8CA3", "FFFFFF")}${cell("B", "FFFFFF", "0F2B53")}${cell("C", "0F2B53", "FFFFFF")}${cell("D", "ADD4DF", "0F2B53")}` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const table = deck.slides[0].elements.find((e) => e.type === "table"); + expect(table?.type).toBe("table"); + if (table?.type !== "table") return; + expect(table.cellFills).toBeTruthy(); + const fills = (table.cellFills ?? []).map((r) => + r.map((c) => (c ?? "").toUpperCase()) + ); + expect(fills[0][0]).toBe("#3F8CA3"); + expect(fills[0][1]).toBe("#FFFFFF"); + expect(fills[1][0]).toBe("#0F2B53"); + expect(fills[1][1]).toBe("#ADD4DF"); + // Per-cell text colours survive too. + const texts = (table.cellTextColors ?? []).map((r) => + r.map((c) => (c ?? "").toUpperCase()) + ); + expect(texts[0][0]).toBe("#FFFFFF"); + expect(texts[1][0]).toBe("#FFFFFF"); + // Non-uniform column widths and row heights are preserved (proportions). + expect(table.colWidths).toEqual([1000000, 5000000]); + expect(table.rowHeights).toEqual([300000, 900000]); + }); + + it("keeps a bold header cell's weight and honours its centre anchor", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // Header cell: bold semibold-named font, vertically centred (anchor="ctr"), + // no — the flat model would otherwise drop the weight + // and top-align it. + const header = + `Situation`; + zip.file( + "ppt/slides/slide1.xml", + `${header}` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const table = deck.slides[0].elements.find((e) => e.type === "table"); + if (table?.type !== "table") return; + // The bold semibold header is kept as a rich run at weight 700. + expect(table.cellRuns?.[0]?.[0]?.[0]?.fontWeight).toBe(700); + // The centre anchor is captured so the renderer doesn't top-align it. + expect(table.cellVAligns?.[0]?.[0]).toBe("middle"); + }); + + it("leaves unfilled cells null so they don't inherit a sibling's fill", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // One cream cell; the other three have no fill at all. Their + // parsed fill must stay null (renderer paints them transparent) — they + // must NOT pick up the cream as a row default, which would flood the grid. + const cream = + `c`; + const empty = + `e`; + zip.file( + "ppt/slides/slide1.xml", + `${cream}${empty}${empty}${empty}` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const table = deck.slides[0].elements.find((e) => e.type === "table"); + if (table?.type !== "table") return; + expect(table.cellFills?.[0]?.[0]?.toUpperCase()).toBe("#F8F6F2"); + // The three fill-less cells stay null — not the cream colour. + expect(table.cellFills?.[0]?.[1]).toBeNull(); + expect(table.cellFills?.[1]?.[0]).toBeNull(); + expect(table.cellFills?.[1]?.[1]).toBeNull(); + }); + + it("trims trailing empty paragraphs but keeps leading ones", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + const empty = ``; + const line = (t: string) => + `${t}`; + // leading blank, content, then two trailing blanks. + const body = empty + line("Xa") + line("Xb") + empty + empty; + zip.file( + "ppt/slides/slide1.xml", + `${body}` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const text = deck.slides[0].elements.find((e) => e.type === "text"); + if (text?.type !== "text") return; + // The two trailing blanks are gone (no trailing newlines), the leading + // blank stays (text begins with a newline). + expect(text.text).toBe("\nXa\nXb"); + }); + + it("omits the bullet glyph on an empty paragraph", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // Two bulleted lines with text, then an empty bulleted paragraph (only + // endParaRPr) — the empty one must NOT render a bullet glyph. + const para = (body: string) => + `${body}`; + const tb = + para("xa") + + para("xb") + + para(""); + zip.file( + "ppt/slides/slide1.xml", + `${tb}` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const text = deck.slides[0].elements.find((e) => e.type === "text"); + if (text?.type !== "text") return; + // Exactly two bullets — the empty third line stays bullet-less. + expect((text.text.match(/•/g) ?? []).length).toBe(2); + }); + + it("draws a dashed outline whose colour comes from the style lnRef", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // A rect with a dashed line that has NO colour of its own — colour must + // come from . + zip.file( + "ppt/slides/slide1.xml", + `` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const shape = deck.slides[0].elements.find((e) => e.type === "shape"); + if (shape?.type !== "shape") return; + expect(shape.stroke?.toUpperCase()).toBe("#204652"); + expect(shape.strokeDash).toBe("lgDash"); + }); + + it("maps the Wingdings 'q' bullet to an empty checkbox glyph", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + zip.file( + "ppt/slides/slide1.xml", + `xa` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const text = deck.slides[0].elements.find((e) => e.type === "text"); + if (text?.type !== "text") return; + expect(text.text).toContain("☐"); + // The raw Wingdings code point must not leak through as a Latin "q". + expect(text.text.startsWith("q")).toBe(false); + }); + + it("synthesises an arrowhead path for block-arrow presets (downArrow)", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + zip.file( + "ppt/slides/slide1.xml", + `` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const shape = deck.slides[0].elements.find((e) => e.type === "shape"); + expect(shape?.type).toBe("shape"); + if (shape?.type !== "shape") return; + expect(shape.path).toBeTruthy(); + // 7-point arrow polygon = 6 line segments, and the tip is at bottom-centre. + expect((shape.path!.d.match(/L/g) ?? []).length).toBe(6); + const { viewW, viewH } = shape.path!; + expect(shape.path!.d).toContain(`${(viewW / 2).toFixed(2)} ${viewH.toFixed(2)}`); + }); + + it("repeats a character bullet on each line of a one-paragraph callout", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // One bulleted paragraph with line breaks between items — the bullet + // glyph must appear on every line, not just the first. + zip.file( + "ppt/slides/slide1.xml", + `xaxbxc` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const text = deck.slides[0].elements.find((e) => e.type === "text"); + if (text?.type !== "text") return; + // Three bulleted lines, one bullet per item. + expect((text.text.match(/•/g) ?? []).length).toBe(3); + expect(text.text).toContain("•"); + expect(text.text.split("\n").length).toBe(3); + }); + + it("keeps the outline of a white-filled chevron that holds text", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // A white chevron with a teal outline holding "xyz": only the border makes + // it visible, so the backing path must carry the stroke. + zip.file( + "ppt/slides/slide1.xml", + `xyz` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const text = deck.slides[0].elements.find((e) => e.type === "text"); + if (text?.type !== "text") return; + expect(text.backingPath).toBeTruthy(); + expect(text.backingPath?.stroke?.toUpperCase()).toBe("#3F8CA3"); + expect((text.backingPath?.strokeWidth ?? 0)).toBeGreaterThan(0); + }); + + it("keeps a text-bearing roundRect's fill, border, and corner radius", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // A white roundRect with a navy outline that hosts text — without keeping + // the box it would vanish into the slide (white-on-white, no border). + zip.file( + "ppt/slides/slide1.xml", + `xa` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const text = deck.slides[0].elements.find((e) => e.type === "text"); + expect(text?.type).toBe("text"); + if (text?.type !== "text") return; + expect(text.background?.toUpperCase()).toBe("#FFFFFF"); + expect(text.borderColor?.toUpperCase()).toBe("#0F2B53"); + expect((text.borderWidth ?? 0)).toBeGreaterThan(0); + expect((text.borderRadius ?? 0)).toBeGreaterThan(0); + }); + + it("only no-wraps SHORT autofit labels, not long autofit paragraphs", async () => { + const make = async (t: string) => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + zip.file( + "ppt/slides/slide1.xml", + `${t}` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const el = deck.slides[0].elements.find((e) => e.type === "text"); + return el?.type === "text" ? el.noWrap : undefined; + }; + expect(await make("02 |")).toBe(true); + expect( + await make("Sparringspartner & Umsetzungs-Begleitung des Mitigationsplans") + ).toBeFalsy(); + }); + + it("keeps rich cell content: ✓ glyphs, highlight, and bullet line breaks", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // A cell with two Wingdings-checkmark bullet paragraphs, each run + // highlighted yellow. Expect ✓ glyphs (mapped from "ü"), preserved line + // breaks, and per-run highlight in cellRuns. + const para = (t: string) => + `${t}`; + const tc = + `${para("xa")}${para("xb")}`; + zip.file( + "ppt/slides/slide1.xml", + `${tc}` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const table = deck.slides[0].elements.find((e) => e.type === "table"); + if (table?.type !== "table") return; + const runs = table.cellRuns?.[0]?.[0]; + expect(runs && runs.length).toBeTruthy(); + const joined = (runs ?? []).map((r) => r.text).join(""); + // Wingdings "ü" became a real check mark, not a stray Latin char. + expect(joined).toContain("✓"); + expect(joined).not.toContain("ü"); + // Line break between the two bullet paragraphs is preserved. + expect(joined).toContain("\n"); + // Highlight rides on the runs. + expect((runs ?? []).some((r) => r.highlight?.toUpperCase() === "#FFFF00")).toBe( + true + ); + }); + + it("spans a merged cell (gridSpan) so a band covers its full width", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // Row with a 3-wide merge: c0 is the gridSpan origin, c1/c2 are hMerge + // continuations covered by it. + const origin = + `band`; + const merged = + ``; + zip.file( + "ppt/slides/slide1.xml", + `${origin}${merged}${merged}` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const table = deck.slides[0].elements.find((e) => e.type === "table"); + if (table?.type !== "table") return; + expect(table.cellSpans?.[0]?.[0]).toEqual({ colSpan: 3 }); + expect(table.cellSpans?.[0]?.[1]?.covered).toBe(true); + expect(table.cellSpans?.[0]?.[2]?.covered).toBe(true); + // The origin keeps its fill across the whole span. + expect(table.cellFills?.[0]?.[0]?.toUpperCase()).toBe("#F8F6F2"); + }); + + it("reads per-side cell borders: colour vs noFill vs unspecified", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // One cell: teal top line (1pt), navy right line (3pt), bottom noFill, + // left side unspecified. The renderer should draw only the two lines. + const tcPr = + `` + + `` + + `` + + `` + + ``; + zip.file( + "ppt/slides/slide1.xml", + `X${tcPr}` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const table = deck.slides[0].elements.find((e) => e.type === "table"); + expect(table?.type).toBe("table"); + if (table?.type !== "table") return; + const cb = table.cellBorders?.[0]?.[0]; + expect(cb).toBeTruthy(); + expect(cb?.t?.color.toUpperCase()).toBe("#3F8CA3"); + expect(cb?.r?.color.toUpperCase()).toBe("#0F2B53"); + // Wider line keeps a larger pixel width than the thin one. + expect((cb?.r?.width ?? 0)).toBeGreaterThan(cb?.t?.width ?? 0); + expect(cb?.b).toBeNull(); // explicit → no line + expect(cb?.l).toBeUndefined(); // side not specified at all + }); + + it("skips shapes flagged hidden='1' (e.g. think-cell data objects)", async () => { + const zip = baseZip(); + zip.file( + "ppt/_rels/presentation.xml.rels", + `` + ); + // Two pictures: one hidden ("do not delete" data object pinned to the + // top-left corner) and one visible. Only the visible one should render. + zip.file( + "ppt/slides/slide1.xml", + `` + ); + zip.file( + "ppt/slides/_rels/slide1.xml.rels", + `` + ); + zip.file("ppt/media/dot.png", ONE_PX_PNG); + + const buffer = await zip.generateAsync({ type: "arraybuffer" }); + const deck = await parsePptx(buffer); + const images = deck.slides[0].elements.filter((e) => e.type === "image"); + // Hidden picture dropped; only the visible one survives. + expect(images.length).toBe(1); + if (images[0]?.type !== "image") return; + // The survivor is the visible picture, not the tiny top-left data object. + expect(images[0].x).toBeGreaterThan(10); + expect(images[0].y).toBeGreaterThan(10); + }); }); diff --git a/packages/slidewise/src/lib/pptx/__tests__/roundtrip.test.ts b/packages/slidewise/src/lib/pptx/__tests__/roundtrip.test.ts index ae98ee5..c50e093 100644 --- a/packages/slidewise/src/lib/pptx/__tests__/roundtrip.test.ts +++ b/packages/slidewise/src/lib/pptx/__tests__/roundtrip.test.ts @@ -436,6 +436,42 @@ describe("pptx round-trip", () => { expect(colors).toContain("#0F1B3D"); }); + it("round-trips a text run highlight colour", async () => { + const deck = makeDeck([ + { + ...baseElement, + id: "t1", + type: "text", + x: 100, + y: 100, + w: 1500, + h: 220, + text: "Aktivität 1", + fontFamily: "Inter", + fontSize: 60, + fontWeight: 400, + italic: false, + underline: false, + strike: false, + color: "#0F2B53", + align: "left", + vAlign: "top", + lineHeight: 1, + letterSpacing: 0, + runs: [{ text: "Aktivität 1", color: "#0F2B53", highlight: "#FFFF00" }], + }, + ]); + + const out = await roundtrip(deck); + const text = out.slides[0].elements.find((e) => e.type === "text"); + expect(text?.type).toBe("text"); + if (text?.type !== "text") return; + expect(text.runs).toBeTruthy(); + const highlighted = (text.runs ?? []).find((r) => r.highlight); + expect(highlighted).toBeTruthy(); + expect((highlighted?.highlight ?? "").toUpperCase()).toBe("#FFFF00"); + }); + it("preserves UnknownElement OOXML and its rels across a round-trip", async () => { // Build a deck with a single hand-crafted UnknownElement carrying a raw // OOXML fragment that references rId7. parsePptx then attaches a fake diff --git a/packages/slidewise/src/lib/pptx/deckToPptx.ts b/packages/slidewise/src/lib/pptx/deckToPptx.ts index 2528f7e..f4e3986 100644 --- a/packages/slidewise/src/lib/pptx/deckToPptx.ts +++ b/packages/slidewise/src/lib/pptx/deckToPptx.ts @@ -10,6 +10,7 @@ import type { ImageElement, LineElement, TableElement, + CellBorderSide, IconElement, EmbedElement, ChartElement, @@ -384,6 +385,7 @@ function addText( ? ({ style: "sng" } as const) : undefined, strike: (r.strike ?? el.strike) ? ("sngStrike" as const) : undefined, + highlight: r.highlight ? hexNoHash(r.highlight) : undefined, charSpacing: r.letterSpacing ?? el.letterSpacing ? Math.round((r.letterSpacing ?? el.letterSpacing) * 100) : undefined, @@ -511,21 +513,94 @@ function addLine( function addTable(s: pptxgen.Slide, el: TableElement): void { if (!el.rows.length) return; const rows = el.rows.map((row, ri) => - row.map((cell) => ({ - text: cell, - options: { - bold: ri === 0, - fill: { color: hexNoHash(ri === 0 ? el.headerFill : el.rowFill) }, - color: hexNoHash(el.textColor), - fontSize: pxToPoints(el.fontSize), - valign: "middle" as const, - }, - })) + row.flatMap((cell, ci) => { + const span = el.cellSpans?.[ri]?.[ci]; + // A cell merged into a neighbour is omitted; pptxgenjs reconstructs the + // merge from the origin cell's colspan/rowspan. + if (span?.covered) return []; + const perCellFill = el.cellFills?.[ri]?.[ci]; + const perCellColor = el.cellTextColors?.[ri]?.[ci]; + // In a per-cell-fill table an unset cell is transparent — fall back to + // the row/header default only for tables without per-cell fills, so we + // don't flood blank cells with a colour borrowed from another cell. + const transparent = + perCellFill === "transparent" || (el.cellFills && !perCellFill); + const fill = transparent + ? { color: "FFFFFF", transparency: 100 } + : { + color: hexNoHash( + perCellFill ?? (ri === 0 ? el.headerFill : el.rowFill) + ), + }; + const cb = el.cellBorders?.[ri]?.[ci]; + // pptxgenjs cell border order is [top, right, bottom, left]; a drawn + // side becomes a solid line, anything else (null / absent) is "none". + const side = (s: CellBorderSide | null | undefined) => + s + ? { type: "solid" as const, color: hexNoHash(s.color), pt: pxToPoints(s.width) } + : { type: "none" as const }; + const border = cb + ? ([side(cb.t), side(cb.r), side(cb.b), side(cb.l)] as [ + pptxgen.BorderProps, + pptxgen.BorderProps, + pptxgen.BorderProps, + pptxgen.BorderProps, + ]) + : undefined; + // Rich runs: emit per-run text (highlight/colour/weight), splitting on + // "\n" so bullet lines break in the exported deck. + const runs = el.cellRuns?.[ri]?.[ci]; + const text: + | string + | { text: string; options?: pptxgen.TextPropsOptions }[] = + runs && runs.length + ? runs.flatMap((r) => { + const pieces = r.text.split("\n"); + return pieces.map((piece, i) => ({ + text: piece, + options: { + fontFace: r.fontFamily, + fontSize: r.fontSize ? pxToPoints(r.fontSize) : undefined, + bold: r.fontWeight ? r.fontWeight >= 600 : undefined, + italic: r.italic, + color: hexNoHash(r.color ?? perCellColor ?? el.textColor), + highlight: r.highlight ? hexNoHash(r.highlight) : undefined, + breakLine: i < pieces.length - 1, + }, + })); + }) + : cell; + return [ + { + text, + options: { + bold: ri === 0, + fill, + color: hexNoHash(perCellColor ?? el.textColor), + fontSize: pxToPoints(el.fontSize), + valign: "middle" as const, + ...(border ? { border } : {}), + ...(span?.colSpan ? { colspan: span.colSpan } : {}), + ...(span?.rowSpan ? { rowspan: span.rowSpan } : {}), + }, + }, + ]; + }) ); + // EMU → inches for pptxgenjs track sizes (preserves uneven Gantt rows/cols). + const EMU_PER_IN = 914400; + const colW = el.colWidths?.length + ? el.colWidths.map((w) => w / EMU_PER_IN) + : undefined; + const rowH = el.rowHeights?.length + ? el.rowHeights.map((h) => h / EMU_PER_IN) + : undefined; s.addTable(rows, { ...geometry(el), border: { type: "none", pt: 0, color: "FFFFFF" }, fontFace: "Inter", + ...(colW ? { colW } : {}), + ...(rowH ? { rowH } : {}), }); } diff --git a/packages/slidewise/src/lib/pptx/pptxToDeck.ts b/packages/slidewise/src/lib/pptx/pptxToDeck.ts index 98c0f56..4912a49 100644 --- a/packages/slidewise/src/lib/pptx/pptxToDeck.ts +++ b/packages/slidewise/src/lib/pptx/pptxToDeck.ts @@ -13,6 +13,9 @@ import type { ImageElement, LineElement, TableElement, + CellBorders, + CellBorderSide, + CellSpan, ChartElement, ChartSeries, GroupElement, @@ -860,11 +863,39 @@ function collectSlidePlaceholderKeys(spTree: any): Set { return keys; } -function isHiddenNode(node: any, nvKey: "p:nvSpPr" | "p:nvPicPr"): boolean { +type NvKey = + | "p:nvSpPr" + | "p:nvPicPr" + | "p:nvCxnSpPr" + | "p:nvGraphicFramePr" + | "p:nvGrpSpPr"; + +function isHiddenNode(node: any, nvKey: NvKey): boolean { const cNvPr = node?.[nvKey]?.["p:cNvPr"]; return cNvPr?.["@_hidden"] === "1" || cNvPr?.["@_hidden"] === 1; } +/** Maps a spTree child tag to the nv-wrapper key that carries its ``. */ +const NV_KEY_BY_TAG: Record = { + "p:sp": "p:nvSpPr", + "p:pic": "p:nvPicPr", + "p:cxnSp": "p:nvCxnSpPr", + "p:graphicFrame": "p:nvGraphicFramePr", + "p:grpSp": "p:nvGrpSpPr", +}; + +/** + * Resolve a shape's outline colour from . + * The colour child is the line colour (idx selects the theme line width/dash + * template, which we don't need for colour). Used when an declares a + * width/dash but no explicit colour of its own. + */ +function resolveStyleLineRef(sp: any, ctx: ParseContext): string | undefined { + const lnRef = sp?.["p:style"]?.["a:lnRef"]; + if (!lnRef) return undefined; + return resolveColor(lnRef, ctx.theme); +} + /** * Resolve a shape's ... * against the theme's fillStyleLst. idx=0 = noFill; 1+ indexes the fillStyleLst @@ -958,6 +989,12 @@ async function parseSpTree( const idx = cursors[tag]++; const node = arr[idx]; if (!node) continue; + // Shapes flagged hidden="1" on their are never rendered by + // PowerPoint (e.g. think-cell's "do not delete" data object, which is a + // tiny off-content OLE picture). Skip them so they don't leak onto the + // slide. + const nvKey = NV_KEY_BY_TAG[tag]; + if (nvKey && isHiddenNode(node, nvKey)) continue; const rawSrc = (node as any)?._elementRawSrc as string | undefined; if (tag === "p:sp") { const el = await parseSpOrText(node, ctx, outer); @@ -1153,6 +1190,26 @@ async function parseSpOrText( flipV ); } + // Block arrows (down/up/left/right): a rectangular shaft plus a triangular + // head. Without a synthesised path these fall back to a plain rectangle, + // losing the arrowhead entirely. + if ( + !customPath && + geom && + (presetName === "downArrow" || + presetName === "upArrow" || + presetName === "leftArrow" || + presetName === "rightArrow") + ) { + customPath = buildBlockArrowPath( + prstGeom, + presetName, + geom.w, + geom.h, + flipH, + flipV + ); + } // Cube: three-face isometric box. The OOXML preset emits front/top/right as // separate sub-paths sharing edges at the inner corner — encoding all three // as `M…Z` sub-paths in one SVG draws the 3D outline (inner edges @@ -1205,18 +1262,66 @@ async function parseSpOrText( const fill = extractShapeFill(sp?.["p:spPr"], ctx.theme) ?? resolveStyleFillRef(sp, ctx); - if (fill && fill !== "transparent") { + // Capture the outline too: an outline-only silhouette (e.g. a white + // chevron with a coloured border holding text) would otherwise vanish + // because only the fill — white on white — would be drawn. + const ln = sp?.["p:spPr"]?.["a:ln"]; + const lnNoFill = ln?.["a:noFill"] !== undefined; + const strokeColor = lnNoFill + ? undefined + : resolveColor(ln?.["a:solidFill"], ctx.theme); + const strokeWidthEmu = + !lnNoFill && ln?.["@_w"] ? Number(ln["@_w"]) : undefined; + const hasFill = !!fill && fill !== "transparent"; + if (hasFill || strokeColor) { el.backingPath = { d: customPath.d, viewW: customPath.viewW, viewH: customPath.viewH, - fill, + fill: hasFill ? fill! : "transparent", fillRule: customPath.fillRule, + ...(strokeColor + ? { + stroke: strokeColor, + strokeWidth: strokeWidthEmu + ? Math.max(1, Math.round(emuToPx(strokeWidthEmu) * ctx.fit.scale)) + : 1, + } + : {}), }; // The path now owns the rendered fill — drop any flat background // the placeholder-fallback path may have set so we don't double up. el.background = undefined; } + } else if (presetName) { + // A preset shape (roundRect/rect/ellipse "speech bubble") that hosts + // text: keep its fill, border, and corner radius behind the text so a + // white-filled bordered box doesn't disappear into the slide. + const shapeFill = extractShapeFill(sp?.["p:spPr"], ctx.theme); + const ln = sp?.["p:spPr"]?.["a:ln"]; + const lnNoFill = ln?.["a:noFill"] !== undefined; + const lnColor = lnNoFill + ? undefined + : resolveColor(ln?.["a:solidFill"], ctx.theme); + const lnWidthEmu = !lnNoFill && ln?.["@_w"] ? Number(ln["@_w"]) : undefined; + if (shapeFill && shapeFill !== "transparent") el.background = shapeFill; + if (lnColor) { + el.borderColor = lnColor; + el.borderWidth = lnWidthEmu + ? Math.max(1, Math.round(emuToPx(lnWidthEmu) * ctx.fit.scale)) + : 1; + } + if (presetName === "roundRect") { + const adj = asArray(prstGeom?.["a:avLst"]?.["a:gd"]).find( + (g: any) => g?.["@_name"] === "adj" + ); + const m = + typeof adj?.["@_fmla"] === "string" + ? /val\s+(-?\d+)/.exec(adj["@_fmla"]) + : null; + const frac = m ? Number(m[1]) / 100000 : 0.16667; + el.borderRadius = Math.round(Math.min(geom.w, geom.h) * frac); + } } return el; } @@ -1246,9 +1351,14 @@ async function parseSpOrText( ?? "transparent"; const lineProps = spPr?.["a:ln"]; const lineHasNoFill = lineProps?.["a:noFill"] !== undefined; + // A line can carry a width/dash but no explicit colour — the colour then + // comes from the shape's (theme line style). Without + // resolving that, a dashed/outlined shape (e.g. a dashed panel border) gets + // no stroke colour and renders invisible. const stroke = lineHasNoFill ? undefined - : resolveColor(lineProps?.["a:solidFill"], ctx.theme); + : resolveColor(lineProps?.["a:solidFill"], ctx.theme) ?? + (lineProps ? resolveStyleLineRef(sp, ctx) : undefined); const strokeWidthEmu = !lineHasNoFill && lineProps?.["@_w"] ? Number(lineProps["@_w"]) @@ -1349,28 +1459,38 @@ function makeTextElement( : (ctx.masterTextDefaults.other ?? []); const masterLvl1 = masterLevels[0]; - // Accumulate inheritance: slide < layout < master < masterDefaults. Each - // level can specify just a subset of fields (the layout might set the - // typeface while only the master defines the colour), so merge field by - // field with earlier candidates winning. + // A shape's own sits at the top of the inheritance + // chain — for a non-placeholder text box (e.g. a manually-styled "02 |" + // label) it's the only place its font size / weight / typeface / colour + // live. Without it those runs fall through to the master default and render + // at the wrong size and font. + const shapeLvlPPr = collectLevelPPrs(effectiveTxBody?.["a:lstStyle"]); + + // Accumulate inheritance: shape lstStyle < slide < layout < master < + // masterDefaults. Each level can specify just a subset of fields (the layout + // might set the typeface while only the master defines the colour), so merge + // field by field with earlier candidates winning. const fallbackRPr = mergeRPrChain( + shapeLvlPPr[0]?.["a:defRPr"], layoutPh?.rPr, masterPh?.rPr, masterLvl1?.["a:defRPr"] ); const fallbackPPr = mergeFirst( + shapeLvlPPr[0], layoutPh?.pPr, masterPh?.pPr, masterLvl1 ); const fallbackBodyPr = mergeFirst(layoutPh?.bodyPr, masterPh?.bodyPr); - // Resolve a per-level [layoutLvl, masterPhLvl, masterTxStyleLvl] chain so - // bullet/alignment/lineSpacing each fall through independently when an - // earlier layer is silent on that particular field. + // Resolve a per-level [shapeLvl, layoutLvl, masterPhLvl, masterTxStyleLvl] + // chain so bullet/alignment/lineSpacing/caps each fall through independently + // when an earlier layer is silent on that particular field. const listStyle: (any | undefined)[][] = []; for (let i = 0; i < 9; i++) { const chain = [ + shapeLvlPPr[i], layoutPh?.lvlPPr?.[i], masterPh?.lvlPPr?.[i], masterLevels[i], @@ -1384,6 +1504,7 @@ function makeTextElement( const autoFit = readNormAutofit( effectiveTxBody?.["a:bodyPr"] ?? fallbackBodyPr ); + const bodyPrForWrap = effectiveTxBody?.["a:bodyPr"] ?? fallbackBodyPr; const text = extractRuns( effectiveTxBody, @@ -1423,7 +1544,7 @@ function makeTextElement( ctx.themeFonts ) ?? "Inter"; - const fontWeight = first?.bold ? 700 : 400; + const fontWeight = first?.fontWeight ?? (first?.bold ? 700 : 400); const color = first?.color ?? resolveColor(fallbackRPr?.["a:solidFill"], ctx.theme) ?? @@ -1433,7 +1554,7 @@ function makeTextElement( text: r.text, fontFamily: r.fontFamily, fontSize: r.fontSize ? Math.max(6, Math.round(r.fontSize * scale)) : undefined, - fontWeight: r.bold ? 700 : r.bold === false ? 400 : undefined, + fontWeight: r.fontWeight ?? (r.bold ? 700 : r.bold === false ? 400 : undefined), italic: r.italic, underline: r.underline, strike: r.strike, @@ -1441,6 +1562,8 @@ function makeTextElement( letterSpacing: r.letterSpacing ? Math.round(r.letterSpacing * scale) : undefined, + highlight: r.highlight, + cap: r.cap, })); const hasMixedFormatting = runs.length > 1 && runs.some((r, i) => { if (i === 0) return false; @@ -1452,9 +1575,15 @@ function makeTextElement( a.fontWeight !== r.fontWeight || a.italic !== r.italic || a.underline !== r.underline || - a.strike !== r.strike + a.strike !== r.strike || + a.highlight !== r.highlight || + a.cap !== r.cap ); }); + // highlight / cap have no flat TextElement field, so they'd be lost when runs + // aren't emitted (single run, or uniform formatting). Force the rich-run + // representation whenever any run carries one. + const hasHighlight = runs.some((r) => r.highlight || r.cap); const el: TextElement = { id: nanoid(8), @@ -1475,7 +1604,18 @@ function makeTextElement( letterSpacing: first?.letterSpacing ? Math.round(first.letterSpacing * scale) : 0, - ...(hasMixedFormatting ? { runs } : {}), + ...(hasMixedFormatting || hasHighlight ? { runs } : {}), + // (and wrap="none") size the shape to its text, + // so a short single-line label like "02 |" must not re-wrap when a + // substitute font measures wider than the original. Restricted to SHORT + // single-line labels: a long autofit paragraph (a content placeholder) + // genuinely wraps within its fixed width and must keep wrapping. + ...((bodyPrForWrap?.["a:spAutoFit"] !== undefined || + bodyPrForWrap?.["@_wrap"] === "none") && + !text.plain.includes("\n") && + text.plain.trim().length <= 16 + ? { noWrap: true } + : {}), }; // Surface per-paragraph layout when any paragraph carries a hanging-indent // pair (marL + negative indent) — that's the signal for a bulleted list @@ -1502,7 +1642,7 @@ function makeTextElement( fontSize: r.fontSize ? Math.max(6, Math.round(r.fontSize * scale)) : undefined, - fontWeight: r.bold ? 700 : r.bold === false ? 400 : undefined, + fontWeight: r.fontWeight ?? (r.bold ? 700 : r.bold === false ? 400 : undefined), italic: r.italic, underline: r.underline, strike: r.strike, @@ -1510,6 +1650,8 @@ function makeTextElement( letterSpacing: r.letterSpacing ? Math.round(r.letterSpacing * scale) : undefined, + highlight: r.highlight, + cap: r.cap, }; }); return { @@ -1558,13 +1700,15 @@ function makeTextElement( // its bounding box. For non-rectangular presets (chevron's left notch + // right arrow tip), that pulls the usable text area inward — without // this, a centred long phrase overflows into the icon/arrow regions. - // Only the inscribed-rectangle insets we can derive from `adj` are added; - // the explicit bodyPr insets stay on top. - const presetTxRect = inscribedTextInsets( - sp?.["p:spPr"]?.["a:prstGeom"], - geom.w, - geom.h - ); + // Only apply it when the shape is actually drawn (has a visible fill): + // a no-fill chevron/homePlate used purely as an invisible text label (e.g. + // the "Phase"/"Content" tabs) has no visible tip to avoid, so reserving it + // would only force the text to wrap in an artificially narrow column. + const shapeFillForInset = extractShapeFill(sp?.["p:spPr"], ctx.theme); + const shapeIsDrawn = !!shapeFillForInset && shapeFillForInset !== "transparent"; + const presetTxRect = shapeIsDrawn + ? inscribedTextInsets(sp?.["p:spPr"]?.["a:prstGeom"], geom.w, geom.h) + : { l: 0, t: 0, r: 0, b: 0 }; const padding = { l: Math.round(emuToPx(lIns) * ctx.fit.scale) + presetTxRect.l, t: Math.round(emuToPx(tIns) * ctx.fit.scale) + presetTxRect.t, @@ -1957,6 +2101,26 @@ async function parseGraphicFrame( } +/** + * Read one cell-border side (``/``/``/``): + * - `undefined` — the side element is absent (cell doesn't specify it) + * - `null` — present with `` (explicit "no line") + * - `{color,width}` — a drawn line; width is the `@w` (EMU) in canvas px + */ +function readCellBorderSide( + ln: any, + theme: ThemeColors +): CellBorderSide | null | undefined { + if (!ln) return undefined; + if (ln["a:noFill"]) return null; + const color = resolveColor(ln["a:solidFill"], theme); + if (!color) return null; + const wEmu = Number(ln["@_w"]); + const width = + Number.isFinite(wEmu) && wEmu > 0 ? Math.max(1, Math.round(emuToPx(wEmu))) : 1; + return { color, width }; +} + function parseTable( gf: any, tbl: any, @@ -2009,22 +2173,113 @@ function parseTable( let firstColor: string | undefined; let headerCellFill: string | undefined; let bodyCellFill: string | undefined; + // Per-cell fill / text colour, indexed [row][col]. PPTX tables (notably + // think-cell Gantt charts) paint individual cells, so we keep every cell's + // own override rather than collapsing to a single header/body fill. + const cellFills: (string | null)[][] = []; + const cellTextColors: (string | null)[][] = []; + const cellBorders: (CellBorders | null)[][] = []; + const cellSpans: (CellSpan | null)[][] = []; + const cellRuns: (TextRun[] | null)[][] = []; + const cellVAligns: (("top" | "middle" | "bottom") | null)[][] = []; + let anyCellFill = false; + let anyCellText = false; + let anyCellBorder = false; + let anyCellSpan = false; + let anyCellRuns = false; + let anyCellVAlign = false; + + // Relative column widths () and row heights (), + // both in EMU. Kept as proportional track sizes for the renderer's CSS grid. + const colWidths = asArray(tbl?.["a:tblGrid"]?.["a:gridCol"]) + .map((gc: any) => Number(gc?.["@_w"])) + .filter((n: number) => Number.isFinite(n) && n > 0); + const rowHeights: number[] = []; for (let ri = 0; ri < trs.length; ri++) { const tr = trs[ri]; + const rowH = Number(tr?.["@_h"]); + rowHeights.push(Number.isFinite(rowH) && rowH > 0 ? rowH : 0); const tcs = asArray(tr["a:tc"]); const cells: string[] = []; + const rowFills: (string | null)[] = []; + const rowTextColors: (string | null)[] = []; + const rowBorders: (CellBorders | null)[] = []; + const rowSpans: (CellSpan | null)[] = []; + const rowRuns: (TextRun[] | null)[] = []; + const rowVAligns: (("top" | "middle" | "bottom") | null)[] = []; for (const tc of tcs) { if (tc?.["@_hMerge"] === "1" || tc?.["@_vMerge"] === "1") { + // Continuation of a merged cell — its slot is covered by the spanning + // origin cell, so render nothing here. cells.push(""); + rowFills.push(null); + rowTextColors.push(null); + rowBorders.push(null); + rowSpans.push({ covered: true }); + rowRuns.push(null); + rowVAligns.push(null); + anyCellSpan = true; continue; } + const colSpan = Number(tc?.["@_gridSpan"]); + const rowSpan = Number(tc?.["@_rowSpan"]); + const span: CellSpan = {}; + if (Number.isFinite(colSpan) && colSpan > 1) span.colSpan = colSpan; + if (Number.isFinite(rowSpan) && rowSpan > 1) span.rowSpan = rowSpan; + const hasSpan = span.colSpan !== undefined || span.rowSpan !== undefined; + if (hasSpan) anyCellSpan = true; + rowSpans.push(hasSpan ? span : null); const txBody = tc["a:txBody"]; const text = txBody ? extractRuns(txBody, ctx.theme, undefined, undefined, ctx.themeFonts) : { plain: "", runs: [] as RunInfo[] }; cells.push(text.plain); + // Preserve rich content (highlight / bullet line breaks / symbol glyphs) + // when the flat string can't represent it. The bullet prefix and "\n" + // breaks are already baked into the run text by extractRuns. + const cScale = ctx.fit.scale; + // A cell is "rich" when the flat table model can't represent its runs: + // line breaks, highlight, all-caps, italic/underline/strike, or a + // bold/semibold weight (the flat path otherwise hardcodes header cells + // to 600 and body cells to 400, dropping the real run weight). + const isRich = + text.plain.includes("\n") || + text.runs.some( + (r) => + r.highlight || + r.cap || + r.italic || + r.underline || + r.strike || + (r.fontWeight !== undefined && r.fontWeight >= 600) + ); + if (isRich && text.runs.length) { + anyCellRuns = true; + rowRuns.push( + text.runs.map((r) => ({ + text: r.text, + fontFamily: r.fontFamily, + fontSize: r.fontSize + ? Math.max(6, Math.round(r.fontSize * cScale)) + : undefined, + fontWeight: r.fontWeight ?? (r.bold ? 700 : r.bold === false ? 400 : undefined), + italic: r.italic, + underline: r.underline, + strike: r.strike, + color: r.color, + letterSpacing: r.letterSpacing + ? Math.round(r.letterSpacing * cScale) + : undefined, + highlight: r.highlight, + cap: r.cap, + })) + ); + } else { + rowRuns.push(null); + } + const r0 = text.runs[0]; if (firstFontSizePx === undefined && r0?.fontSize) { firstFontSizePx = Math.max(8, Math.round(r0.fontSize * ctx.fit.scale)); @@ -2032,17 +2287,55 @@ function parseTable( if (!firstColor && r0?.color) firstColor = r0.color; // Cell-level wins over style fills (PPTX - // override semantics): record it here so the table-level defaults - // we pick below don't clobber it. We can't model per-cell fills - // yet, so the *first* explicit cell fill on the header / body - // wins for the whole row class. - const cellFill = resolveColor(tc?.["a:tcPr"]?.["a:solidFill"], ctx.theme); + // override semantics). An explicit reads as transparent so + // the slide background shows through (e.g. the blank top-left corner). + const tcPr = tc?.["a:tcPr"]; + const cellFill = resolveColor(tcPr?.["a:solidFill"], ctx.theme); + const fillVal = cellFill ?? (tcPr?.["a:noFill"] ? "transparent" : null); + rowFills.push(fillVal); + if (fillVal) anyCellFill = true; + // Record the row-class fallbacks too (back-compat with the flat model). if (cellFill) { if (ri === 0 && headerCellFill === undefined) headerCellFill = cellFill; else if (ri > 0 && bodyCellFill === undefined) bodyCellFill = cellFill; } + + const cellText = r0?.color ?? null; + rowTextColors.push(cellText); + if (cellText) anyCellText = true; + + // Per-cell vertical anchor (): t / ctr / b. + const vAlign = readBodyVAlign(tcPr) ?? null; + rowVAligns.push(vAlign); + if (vAlign) anyCellVAlign = true; + + // Per-side cell borders (). A side may be a coloured + // line, an explicit (null = no line), or absent (undefined). + const borders: CellBorders = {}; + let hasBorder = false; + const SIDES: [keyof CellBorders, string][] = [ + ["l", "a:lnL"], + ["r", "a:lnR"], + ["t", "a:lnT"], + ["b", "a:lnB"], + ]; + for (const [key, tag] of SIDES) { + const side = readCellBorderSide(tcPr?.[tag], ctx.theme); + if (side !== undefined) { + borders[key] = side; + hasBorder = true; + } + } + if (hasBorder) anyCellBorder = true; + rowBorders.push(hasBorder ? borders : null); } rows.push(cells); + cellFills.push(rowFills); + cellTextColors.push(rowTextColors); + cellBorders.push(rowBorders); + cellSpans.push(rowSpans); + cellRuns.push(rowRuns); + cellVAligns.push(rowVAligns); } // Resolve final fills with precedence: cell-level override > style part > whole-table > built-in default. @@ -2069,6 +2362,18 @@ function parseTable( ...(hasLastCol && lastColFill ? { lastColFill } : {}), ...(hasHeader && headerStyleText ? { headerTextColor: headerStyleText } : {}), ...(hasFirstCol && firstColText ? { firstColTextColor: firstColText } : {}), + ...(anyCellFill ? { cellFills } : {}), + ...(anyCellText ? { cellTextColors } : {}), + ...(anyCellBorder ? { cellBorders } : {}), + ...(anyCellSpan ? { cellSpans } : {}), + ...(anyCellRuns ? { cellRuns } : {}), + ...(anyCellVAlign ? { cellVAligns } : {}), + ...(colWidths.length === (rows[0]?.length ?? 0) && colWidths.length > 0 + ? { colWidths } + : {}), + ...(rowHeights.length === rows.length && rowHeights.every((h) => h > 0) + ? { rowHeights } + : {}), }; return table; } @@ -3151,16 +3456,55 @@ function readAlign(pPr: any): "left" | "center" | "right" | undefined { return undefined; } +/** + * Many brand fonts encode their weight in the family NAME ("Gilroy ExtraBold", + * "… Medium", "… Light", "… Semibold"). When that font isn't installed and we + * fall back to a generic, the substitute renders at the wrong heaviness unless + * we translate the name into a numeric font-weight. Returns undefined when the + * name carries no weight hint (caller then uses the bold attribute). + */ +function weightFromFamilyName(family?: string): number | undefined { + if (!family) return undefined; + const f = family.toLowerCase(); + if (/extra[ -]?light|ultra[ -]?light/.test(f)) return 200; + if (/\bthin\b|hairline/.test(f)) return 100; + if (/extra[ -]?bold|ultra[ -]?bold/.test(f)) return 800; + if (/\bblack\b|\bheavy\b/.test(f)) return 900; + if (/semi[ -]?bold|demi[ -]?bold/.test(f)) return 600; + if (/\bbold\b/.test(f)) return 700; + if (/\bmedium\b/.test(f)) return 500; + if (/\blight\b/.test(f)) return 300; + return undefined; +} + +/** Effective numeric weight from the family-name hint plus the bold attribute. */ +function effectiveFontWeight( + family: string | undefined, + bold: boolean | undefined +): number | undefined { + const named = weightFromFamilyName(family); + if (named !== undefined) { + // A bold attribute can only push a lighter named weight up to bold. + return bold && named < 700 ? 700 : named; + } + if (bold === true) return 700; + if (bold === false) return 400; + return undefined; +} + interface RunInfo { text: string; fontFamily?: string; fontSize?: number; bold?: boolean; + fontWeight?: number; italic?: boolean; underline?: boolean; strike?: boolean; color?: string; letterSpacing?: number; + highlight?: string; + cap?: "all" | "small"; } interface ParagraphInfo { @@ -3198,7 +3542,24 @@ function extractRuns( const autoNumCounters = new Map(); let prevAutoKey: string | undefined; - for (let pi = 0; pi < paragraphs.length; pi++) { + // PowerPoint draws no visible line for an empty paragraph at the END of a + // text body, so drop trailing blank paragraphs (a run with text, a field, + // or a hard break counts as content). Leading / interior blanks are kept — + // they create real spacing (e.g. a list pushed down from the top). + const paragraphHasContent = (p: any): boolean => { + if (p?.["a:br"] || p?.["a:fld"]) return true; + return asArray(p?.["a:r"]).some((r: any) => { + const t = r?.["a:t"]; + const s = typeof t === "string" ? t : t?.["#text"] ?? ""; + return String(s).length > 0; + }); + }; + let lastContentIdx = -1; + for (let i = 0; i < paragraphs.length; i++) { + if (paragraphHasContent(paragraphs[i])) lastContentIdx = i; + } + + for (let pi = 0; pi <= lastContentIdx; pi++) { const p = paragraphs[pi]; const pPr = p?.["a:pPr"]; const lvl = clampLevel(Number(pPr?.["@_lvl"] ?? 0)); @@ -3262,10 +3623,21 @@ function extractRuns( const flds = asArray(p?.["a:fld"]); const paraStart = runs.length; const paragraphText: string[] = []; - if (prefix.text) paragraphText.push(prefix.text); + // The bullet prefix is added later, but only when the paragraph actually + // has text — PowerPoint shows no bullet glyph on an empty line. + + // `cap="all"`/`cap="small"` is commonly set only on the placeholder's + // list-style , so resolve it across the level chain when the run + // (and its direct fallback) are silent. Inherited along pPr defRPr too. + const levelCap = levelChain + .map((s) => s?.["a:defRPr"]?.["@_cap"] ?? s?.["@_cap"]) + .find((v) => v !== undefined); const onRun = (r: any, isFld: boolean) => { const built = buildRunInfo(r, theme, themeFonts, fallbackRPr); + if (!built.run.cap && (levelCap === "all" || levelCap === "small")) { + built.run.cap = levelCap; + } // PowerPoint stores field placeholders as with a // template literal in (e.g. "‹#›" for slidenum). The literal is // only meant for design-time display; renderers replace it with the @@ -3305,15 +3677,37 @@ function extractRuns( } // Prepend the bullet prefix to the first run of this paragraph so it - // survives renderers that walk `runs` instead of the joined `plain` text. - if (prefix.text && runs.length > paraStart) { + // survives renderers that walk `runs` instead of the joined `plain` text — + // but only when the paragraph carries real text. An empty bulleted line + // (template placeholder) shows no bullet in PowerPoint, so leaving the + // glyph off keeps lists from sprouting stray ☐ / – markers. + const paraHasText = runs + .slice(paraStart) + .some((r) => r.text.trim().length > 0); + if (prefix.text && paraHasText && runs.length > paraStart) { runs[paraStart].text = prefix.text + runs[paraStart].text; + paragraphText.unshift(prefix.text); + // think-cell encodes a multi-item callout as ONE bulleted paragraph with + // embedded line breaks ("xa\nxb\nxc"); PowerPoint shows the bullet glyph + // on every line. Repeat a *character* bullet after each in-paragraph + // break so continuation lines aren't left bullet-less. (Auto-numbered + // bullets are skipped — repeating the same number would be wrong.) + if (bullet.kind === "char") { + for (let ri = paraStart; ri < runs.length; ri++) { + runs[ri].text = runs[ri].text.replace(/\n/g, `\n${prefix.text}`); + } + // Keep the paragraph's flat text in sync with the repeated bullets. + const rebuilt = runs.slice(paraStart).map((r) => r.text).join(""); + paragraphText.length = 0; + paragraphText.push(rebuilt); + } } // Carry the inter-paragraph break onto the last run we just emitted — // renderers that walk `runs` (mixed-formatting path) would otherwise - // concatenate paragraphs into one long line. - if (pi < paragraphs.length - 1 && runs.length > 0) { + // concatenate paragraphs into one long line. Use lastContentIdx (not the + // raw count) so the final kept paragraph gets no trailing break. + if (pi < lastContentIdx && runs.length > 0) { runs[runs.length - 1].text += "\n"; } @@ -3359,13 +3753,43 @@ interface ResolvedBullet { autoStartAt?: number; } +/** + * Symbol-font bullet glyphs PowerPoint draws via a private code page (Wingdings + * etc.) map to nonsense Latin characters when rendered in a normal font — the + * classic "ü" instead of a check mark. Translate the common ones to their + * Unicode equivalents so they render as intended without the symbol font. + */ +const SYMBOL_FONT_RE = /wingdings|webdings|symbol/i; +const SYMBOL_BULLET_MAP: Record = { + "ü": "✓", // Wingdings ü → ✓ check mark + "ý": "✓", // Wingdings ý → ✓ (boxed check, approximated) + "û": "✗", // Wingdings û → ✗ ballot X + "þ": "✗", // Wingdings þ → ✗ (boxed X, approximated) + "§": "▪", // Wingdings § → ▪ small square + "Ø": "→", // Wingdings Ø → → arrow + "q": "☐", // Wingdings q → ☐ empty checkbox + "r": "☐", // Wingdings r → ☐ (boxed variant, approximated) + "R": "☒", // Wingdings R → ☒ checked box + "v": "❖", // Wingdings v → ❖ diamond +}; + +function mapSymbolBulletChar(char: string, font: string | undefined): string { + if (font && SYMBOL_FONT_RE.test(font) && SYMBOL_BULLET_MAP[char]) { + return SYMBOL_BULLET_MAP[char]; + } + return char; +} + function resolveBullet(sources: (any | undefined)[]): ResolvedBullet { // First source that defines any of buNone/buChar/buAutoNum wins. for (const src of sources) { if (!src) continue; if (src["a:buNone"] !== undefined) return { kind: "none" }; - if (src["a:buChar"]?.["@_char"]) - return { kind: "char", char: String(src["a:buChar"]["@_char"]) }; + if (src["a:buChar"]?.["@_char"]) { + const rawChar = String(src["a:buChar"]["@_char"]); + const font = src["a:buFont"]?.["@_typeface"]; + return { kind: "char", char: mapSymbolBulletChar(rawChar, font) }; + } if (src["a:buAutoNum"]) { return { kind: "auto", @@ -3504,10 +3928,16 @@ function buildRunInfo( const color = resolveColor(rPr?.["a:solidFill"], theme) ?? resolveColor(fallbackRPr?.["a:solidFill"], theme); + // wraps a colour child (srgbClr/schemeClr) just like a fill, + // so resolveColor handles it directly. Rendered as the text background. + const highlight = + resolveColor(rPr?.["a:highlight"], theme) ?? + resolveColor(fallbackRPr?.["a:highlight"], theme); const boldVal = rPr?.["@_b"] ?? fallbackRPr?.["@_b"]; const italicVal = rPr?.["@_i"] ?? fallbackRPr?.["@_i"]; const underlineVal = rPr?.["@_u"] ?? fallbackRPr?.["@_u"]; const strikeVal = rPr?.["@_strike"] ?? fallbackRPr?.["@_strike"]; + const capVal = rPr?.["@_cap"] ?? fallbackRPr?.["@_cap"]; return { text, run: { @@ -3515,11 +3945,17 @@ function buildRunInfo( fontFamily, fontSize, bold: boldVal === "1" || boldVal === 1, + fontWeight: effectiveFontWeight( + fontFamily, + boldVal === undefined ? undefined : boldVal === "1" || boldVal === 1 + ), italic: italicVal === "1" || italicVal === 1, underline: !!(underlineVal && underlineVal !== "none"), strike: strikeVal === "sngStrike", color, letterSpacing, + highlight, + cap: capVal === "all" || capVal === "small" ? capVal : undefined, }, }; } @@ -3983,6 +4419,64 @@ function buildChevronPath( return { d, viewW: W, viewH: H }; } +/** + * Build an SVG path for the cardinal block-arrow presets (down/up/left/right + * Arrow). `adj1` sets the shaft thickness and `adj2` the arrowhead length, + * both as a fraction of the shorter side (matching the OOXML preset guides). + */ +function buildBlockArrowPath( + prstGeom: any, + preset: string, + w: number, + h: number, + flipH: boolean, + flipV: boolean +): ShapePath { + const W = Math.max(1, w); + const H = Math.max(1, h); + const adjBy = (name: string, fallback: number): number => { + const adj = asArray(prstGeom?.["a:avLst"]?.["a:gd"]).find( + (g: any) => g?.["@_name"] === name + ); + const fmla: string | undefined = adj?.["@_fmla"]; + const m = typeof fmla === "string" ? /val\s+(-?\d+)/.exec(fmla) : null; + return m ? Number(m[1]) : fallback; + }; + const ss = Math.min(W, H); + const a1 = Math.max(0, Math.min(100000, adjBy("adj1", 50000))); + const a2 = Math.max(0, Math.min(100000, adjBy("adj2", 50000))); + const shaftHalf = (ss * a1) / 200000; // half the shaft thickness + const headLen = (ss * a2) / 100000; // arrowhead length along the arrow axis + let pts: [number, number][]; + if (preset === "downArrow" || preset === "upArrow") { + const x1 = W / 2 - shaftHalf; + const x2 = W / 2 + shaftHalf; + if (preset === "downArrow") { + const y1 = H - headLen; + pts = [[x1, 0], [x1, y1], [0, y1], [W / 2, H], [W, y1], [x2, y1], [x2, 0]]; + } else { + const y1 = headLen; + pts = [[x1, H], [x1, y1], [0, y1], [W / 2, 0], [W, y1], [x2, y1], [x2, H]]; + } + } else { + const y1 = H / 2 - shaftHalf; + const y2 = H / 2 + shaftHalf; + if (preset === "rightArrow") { + const x1 = W - headLen; + pts = [[0, y1], [x1, y1], [x1, 0], [W, H / 2], [x1, H], [x1, y2], [0, y2]]; + } else { + const x1 = headLen; + pts = [[W, y1], [x1, y1], [x1, 0], [0, H / 2], [x1, H], [x1, y2], [W, y2]]; + } + } + const mapped = pts.map(([x, y]) => [flipH ? W - x : x, flipV ? H - y : y]); + const d = + "M " + + mapped.map(([x, y]) => `${x.toFixed(2)} ${y.toFixed(2)}`).join(" L ") + + " Z"; + return { d, viewW: W, viewH: H }; +} + /** * Build an SVG path for the cube preset. `adj` (default 25%) controls the * apparent depth — it's a percentage of the shorter side that becomes both @@ -4066,10 +4560,10 @@ function mapPrstToKind(prst?: string): ShapeKind | null { case "plus": case "cube": case "can": - case "leftArrow": - case "rightArrow": - case "upArrow": - case "downArrow": + // NOTE: the cardinal block arrows (left/right/up/downArrow) are + // intentionally NOT mapped here — they get a synthesised arrow `path` + // (buildBlockArrowPath) and fall through to the null-kind branch that + // attaches it, so they render with their arrowhead instead of as a rect. case "leftRightArrow": case "upDownArrow": case "bentArrow": diff --git a/packages/slidewise/src/lib/types.ts b/packages/slidewise/src/lib/types.ts index e003fe9..f0000c2 100644 --- a/packages/slidewise/src/lib/types.ts +++ b/packages/slidewise/src/lib/types.ts @@ -87,6 +87,19 @@ export interface TextRun { strike?: boolean; color?: string; letterSpacing?: number; + /** + * Optional text highlight colour (CSS background behind the glyphs), from + * OOXML ``. PowerPoint renders this like a highlighter + * pen — common in think-cell decks for yellow callouts. + */ + highlight?: string; + /** + * Optional letter-case transform from OOXML ``: `"all"` + * (all-caps) or `"small"` (small caps). PowerPoint applies this at render + * time without changing the stored characters, so it's often inherited from + * a placeholder's list style rather than set on the run. + */ + cap?: "all" | "small"; } export interface TextElement extends BaseElement { @@ -116,6 +129,23 @@ export interface TextElement extends BaseElement { * the fill if rendered as a separate underlay shape. */ background?: string; + /** + * Optional box outline / corner radius for a text-bearing preset shape + * (e.g. a roundRect "speech bubble" containing bullets). The PPTX importer + * sets these from the shape's `` and `roundRect` adjust value so the + * box renders its border and rounded corners behind the text — otherwise a + * white-filled bordered shape with text would vanish into the slide. + */ + borderColor?: string; + borderWidth?: number; + borderRadius?: number; + /** + * When true the text renders on a single line without wrapping. Set by the + * PPTX importer for `` / `wrap="none"` boxes whose + * bounds were fitted to a single line — prevents a substitute font from + * wrapping content the original kept on one line. + */ + noWrap?: boolean; /** * Optional vector glyph drawn behind the text. Set by the PPTX importer * when the layout placeholder carried an `` (typically a @@ -128,6 +158,14 @@ export interface TextElement extends BaseElement { viewH: number; fill: string; fillRule?: "nonzero" | "evenodd"; + /** + * Optional outline for the silhouette (from the shape's ``). Needed + * for outline-only shapes — e.g. a white-filled chevron with a coloured + * border that holds text; without the stroke it would vanish on a white + * slide. + */ + stroke?: string; + strokeWidth?: number; }; /** * Optional inner padding (in canvas pixels) for the text box. The PPTX @@ -263,6 +301,38 @@ export interface LineElement extends BaseElement { glow?: GlowSpec; } +/** One side of a table cell border. */ +export interface CellBorderSide { + /** CSS colour of the line. */ + color: string; + /** Line width in canvas pixels. */ + width: number; +} + +/** + * Per-side borders for a single table cell. A side set to a {@link CellBorderSide} + * draws that line; `null` is an explicit "no line" (``); an absent + * side means the cell didn't specify it (fall back to neighbour / default). + */ +export interface CellBorders { + t?: CellBorderSide | null; + r?: CellBorderSide | null; + b?: CellBorderSide | null; + l?: CellBorderSide | null; +} + +/** + * Span metadata for a table cell, indexed alongside `rows`. An origin cell + * carries `colSpan`/`rowSpan` (> 1) when it merges neighbours; a cell that has + * been merged away carries `covered: true` and is not rendered (its grid slot + * is occupied by the spanning origin). + */ +export interface CellSpan { + colSpan?: number; + rowSpan?: number; + covered?: boolean; +} + export interface TableElement extends BaseElement { type: "table"; rows: string[][]; @@ -302,6 +372,63 @@ export interface TableElement extends BaseElement { headerTextColor?: string; /** First-column text colour override. */ firstColTextColor?: string; + /** + * Optional per-cell fill overrides, indexed `[row][col]` parallel to `rows`. + * A non-null entry wins over headerFill / rowFill / banding for that cell. + * PPTX tables — especially think-cell Gantt charts — colour individual cells + * (the bars, milestones, and header strips are cell fills); the row-class + * fills above are only the fallback for cells left null. `"transparent"` + * represents an explicit ``. + */ + cellFills?: (string | null)[][]; + /** + * Optional per-cell text-colour overrides, indexed `[row][col]` parallel to + * `rows`. Falls back to headerTextColor / firstColTextColor / textColor. + */ + cellTextColors?: (string | null)[][]; + /** + * Optional per-cell rich runs, indexed `[row][col]` parallel to `rows`. Set + * when a cell carries formatting the flat `rows` string can't express — + * highlight (think-cell yellow callouts), per-run fonts, bullet line breaks, + * or mapped symbol glyphs (✓). When present the renderer/serializer use + * these; cells without rich runs fall back to the plain string. + */ + cellRuns?: (TextRun[] | null)[][]; + /** + * Optional per-cell vertical alignment from ``, indexed + * `[row][col]`. `null` means unspecified (renderer falls back to its + * header/body default). PPTX table cells default to top anchor; cells with + * `anchor="ctr"`/`"b"` must centre / bottom-align their content. + */ + cellVAligns?: (("top" | "middle" | "bottom") | null)[][]; + /** + * Optional per-cell borders, indexed `[row][col]` parallel to `rows`. Each + * cell names up to four sides; a side is a line (`{color,width}`), `null` + * for an explicit `` (no line), or absent when the cell doesn't + * specify that side. PPTX tables (think-cell especially) draw only a few + * coloured edges and leave the rest blank — modelling sides individually is + * what stops the renderer from painting a full grey grid. + */ + cellBorders?: (CellBorders | null)[][]; + /** + * Optional per-cell span metadata, indexed `[row][col]` parallel to `rows`. + * Present only when the table merges cells (``/`hMerge`/ + * `rowSpan`/`vMerge`). Lets a merged cell (e.g. a full-width band) cover the + * columns it spans instead of stopping after one column. + */ + cellSpans?: (CellSpan | null)[][]; + /** + * Optional relative column widths from `` (EMU). Used + * proportionally as CSS grid track sizes so a narrow number column and a wide + * label column keep their shape. Falls back to equal columns when absent. + */ + colWidths?: number[]; + /** + * Optional relative row heights from `` (EMU). Used proportionally as + * CSS grid track sizes — PPTX tables (think-cell Gantts especially) rely on + * uneven row heights. Falls back to equal rows when absent. + */ + rowHeights?: number[]; } export interface IconElement extends BaseElement {