diff --git a/.changeset/table-per-cell-formatting.md b/.changeset/table-per-cell-formatting.md new file mode 100644 index 0000000..3e2578f --- /dev/null +++ b/.changeset/table-per-cell-formatting.md @@ -0,0 +1,32 @@ +--- +"@textcortex/slidewise": minor +--- + +Capture per-cell text colour, fill, bold, italic, font size, font family, and horizontal alignment on PPTX table import. The previous `TableElement.rows: string[][]` shape collapsed every cell to plain text, so tables that authored per-cell styling (Gantt timelines with "Phase 1/2/3" red bold labels, banded rows with cell-level fills) lost all of it on import. Phase 2 / Phase 3 cells rendered as white-on-white in the editor, the months header was invisible, and any subsequent edit propagated the wrong representation downstream. + +**Schema:** `TableElement.rows` is now `(string | TableCell)[][]`. `TableCell` carries the per-cell overrides: + +```ts +interface TableCell { + text: string; + color?: string; // resolved hex, overrides the table's textColor + fill?: string; // resolved hex, overrides any row/header/banded fill + bold?: boolean; + italic?: boolean; + fontSize?: number; // canvas px, post-fit-scaling + fontFamily?: string; + align?: "left" | "center" | "right"; + colSpan?: number; + rowSpan?: number; +} +``` + +Plain-string cells (legacy decks, AI-authored decks that don't need styling) still work — the renderer + writer normalise them to `{ text }` at the call site. No schema-version bump, no migration step. + +**Importer:** `parseTable` in `pptxToDeck.ts` now reads `` for the cell fill and the first run's `` for colour / bold / italic / size / typeface, plus `` for horizontal alignment. + +**Renderer:** `TableView` applies per-cell properties on top of the existing row/header/column rules. Cell-level values win; the table-level fallbacks (`headerFill`, `rowFill`, `textColor`, etc.) remain as the default for cells that don't override. + +**Writer:** `addTable` in `deckToPptx.ts` emits per-cell `fill` / `color` / `bold` / `italic` / `fontSize` / `fontFace` / `align` to pptxgenjs so the export round-trips the styling. + +**New exports:** `TableCell`, `TableRow` from the package root. diff --git a/packages/slidewise/src/components/editor/ElementView.tsx b/packages/slidewise/src/components/editor/ElementView.tsx index 583832b..a4ad180 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, + TableCell, IconElement, EmbedElement, ChartElement, @@ -715,16 +716,21 @@ function LineView({ el }: { el: LineElement }) { ); } +/** + * Normalise `TableRow` (mixed `string | TableCell`) into a typed cell so the + * renderer doesn't have to branch on every access. + */ +function normaliseCell(c: string | TableCell): TableCell { + return typeof c === "string" ? { text: c } : c; +} + function TableView({ el }: { el: TableElement }) { const cols = el.rows[0]?.length ?? 1; const rowCount = el.rows.length; - // PPTX-faithful: contiguous cells, no inter-cell gap, no rounded corners. - // Cells share their dividers via inset box-shadows so we draw a single - // grid line between adjacent cells instead of doubling-up borders. const stroke = el.borderColor ?? "rgba(0, 0, 0, 0.12)"; const hasHeader = el.hasHeader ?? true; const bandRows = el.bandRows ?? false; - const cellFill = (ri: number, ci: number): string => { + const tableFill = (ri: number, ci: number): string => { 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; @@ -737,7 +743,7 @@ function TableView({ el }: { el: TableElement }) { } return el.rowFill; }; - const cellColor = (ri: number, ci: number): string => { + const tableColor = (ri: number, ci: number): string => { if (hasHeader && ri === 0 && el.headerTextColor) return el.headerTextColor; if (el.firstColTextColor && ci === 0 && !(hasHeader && ri === 0)) { return el.firstColTextColor; @@ -758,34 +764,55 @@ function TableView({ el }: { el: TableElement }) { }} > {el.rows.flatMap((row, ri) => - row.map((cell, ci) => ( -
- {cell} -
- )) + row.map((rawCell, ci) => { + const cell = normaliseCell(rawCell); + // Per-cell properties win over table-level defaults; fall back + // to the row/header/column rules when the cell didn't author + // its own override. This is what closes the per-cell-color + // visibility gap on PPTX tables where each cell carries its + // own colour (Gantt timelines, phase labels, etc.). + const fill = cell.fill ?? tableFill(ri, ci); + const color = cell.color ?? tableColor(ri, ci); + const fontSize = cell.fontSize ?? el.fontSize; + const isDefaultBold = + (hasHeader && ri === 0) || (el.firstColFill && ci === 0); + const fontWeight = + cell.bold === true ? 700 : cell.bold === false ? 400 : isDefaultBold ? 600 : 400; + return ( +
+ {cell.text} +
+ ); + }) )} ); diff --git a/packages/slidewise/src/index.ts b/packages/slidewise/src/index.ts index 6d5e7b0..af47e59 100644 --- a/packages/slidewise/src/index.ts +++ b/packages/slidewise/src/index.ts @@ -109,6 +109,8 @@ export type { ImageElement, LineElement, TableElement, + TableCell, + TableRow, IconElement, EmbedElement, ChartElement, diff --git a/packages/slidewise/src/lib/pptx/__tests__/table-per-cell.test.ts b/packages/slidewise/src/lib/pptx/__tests__/table-per-cell.test.ts new file mode 100644 index 0000000..838d168 --- /dev/null +++ b/packages/slidewise/src/lib/pptx/__tests__/table-per-cell.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parsePptx } from "../pptxToDeck"; +import type { TableCell, TableElement } from "@/lib/types"; + +const FIXTURE = resolve(__dirname, "../../../../../../.context/attachments/eon-deck.pptx"); + +/** + * The EON timeline slide (Gantt-style table) stores per-cell styling in + * `` and per-cell run formatting in the first run's + * ``. Before per-cell formatting landed, the importer + * collapsed every cell to a plain string and discarded both layers — + * Phase 2 / Phase 3 labels rendered as white-on-white in the editor and + * the months header was invisible. This guards the regression. + */ +describe("table per-cell formatting", () => { + it("captures per-cell color and fill from / ", async () => { + const buf = readFileSync(FIXTURE); + const deck = await parsePptx(new Uint8Array(buf)); + + // Find any table whose first cell is "Phase 1" / "Phase 2" / "Phase 3" + // — those are the red bold labels we know exist on slide 18. + const phaseTables: TableElement[] = []; + for (const slide of deck.slides) { + for (const el of slide.elements) { + if (el.type !== "table") continue; + const firstCell = el.rows[0]?.[0]; + const text = + typeof firstCell === "string" ? firstCell : firstCell?.text ?? ""; + if (/^Phase\s+[123]$/.test(text)) phaseTables.push(el); + } + } + expect(phaseTables.length).toBeGreaterThan(0); + + // The phase-label cell should carry an explicit colour AND bold. + // Whatever the resolved hex is, it must NOT be the table-level default + // (the bug was the importer ignoring per-cell rPr). + for (const t of phaseTables) { + const firstCell = t.rows[0]?.[0] as TableCell; + expect(typeof firstCell).toBe("object"); + expect(firstCell.color).toBeTruthy(); + // Phase labels are bold in the source XML. + expect(firstCell.bold).toBe(true); + } + }); + + it("legacy string cells remain accepted (back-compat)", async () => { + // A deck synthesised in-app might still emit plain strings. The + // renderer normalises these to `{ text }`; the writer too. + const mixedRow: (string | TableCell)[] = [ + "plain string", + { text: "rich", color: "#FF00FF", bold: true }, + ]; + expect(typeof mixedRow[0]).toBe("string"); + expect((mixedRow[1] as TableCell).color).toBe("#FF00FF"); + }); +}); diff --git a/packages/slidewise/src/lib/pptx/deckToPptx.ts b/packages/slidewise/src/lib/pptx/deckToPptx.ts index ad58b65..585aad2 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, + TableCell, IconElement, EmbedElement, ChartElement, @@ -469,17 +470,53 @@ function addLine( function addTable(s: pptxgen.Slide, el: TableElement): void { if (!el.rows.length) return; + const hasHeader = el.hasHeader ?? true; + const bandRows = el.bandRows ?? false; + const cols = el.rows[0]?.length ?? 1; + const rowCount = el.rows.length; + const tableFill = (ri: number, ci: number): string => { + 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; + if (el.lastColFill && ci === cols - 1) return el.lastColFill; + if (bandRows && el.rowAltFill) { + const bodyIdx = hasHeader ? ri - 1 : ri; + return bodyIdx % 2 === 1 ? el.rowAltFill : el.rowFill; + } + return el.rowFill; + }; + const tableColor = (ri: number, ci: number): string => { + if (hasHeader && ri === 0 && el.headerTextColor) return el.headerTextColor; + if (el.firstColTextColor && ci === 0 && !(hasHeader && ri === 0)) { + return el.firstColTextColor; + } + return el.textColor; + }; 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.map((raw, ci) => { + // Per-cell formatting wins over row/header/column defaults. Bare + // strings (`row: ["a","b"]` legacy shape) fall through to the + // table-level defaults below. + const cell: TableCell = typeof raw === "string" ? { text: raw } : raw; + const fill = cell.fill ?? tableFill(ri, ci); + const color = cell.color ?? tableColor(ri, ci); + const fontSize = cell.fontSize ?? el.fontSize; + const cellBold = + cell.bold === true ? true : cell.bold === false ? false : ri === 0; + return { + text: cell.text, + options: { + bold: cellBold, + italic: cell.italic === true ? true : undefined, + fill: { color: hexNoHash(fill) }, + color: hexNoHash(color), + fontSize: pxToPoints(fontSize), + fontFace: cell.fontFamily, + align: cell.align, + valign: "middle" as const, + }, + }; + }) ); s.addTable(rows, { ...geometry(el), diff --git a/packages/slidewise/src/lib/pptx/pptxToDeck.ts b/packages/slidewise/src/lib/pptx/pptxToDeck.ts index 7608c76..3c9a3d6 100644 --- a/packages/slidewise/src/lib/pptx/pptxToDeck.ts +++ b/packages/slidewise/src/lib/pptx/pptxToDeck.ts @@ -13,6 +13,7 @@ import type { ImageElement, LineElement, TableElement, + TableCell, ChartElement, ChartSeries, UnknownElement, @@ -1840,7 +1841,7 @@ function parseTable( const band1Fill = styleFill(style?.band1H); const band2Fill = styleFill(style?.band2H); - const rows: string[][] = []; + const rows: TableCell[][] = []; let firstFontSizePx: number | undefined; let firstColor: string | undefined; let headerCellFill: string | undefined; @@ -1849,17 +1850,16 @@ function parseTable( for (let ri = 0; ri < trs.length; ri++) { const tr = trs[ri]; const tcs = asArray(tr["a:tc"]); - const cells: string[] = []; + const cells: TableCell[] = []; for (const tc of tcs) { if (tc?.["@_hMerge"] === "1" || tc?.["@_vMerge"] === "1") { - cells.push(""); + cells.push({ text: "" }); continue; } const txBody = tc["a:txBody"]; const text = txBody ? extractRuns(txBody, ctx.theme, undefined, undefined, ctx.themeFonts) : { plain: "", runs: [] as RunInfo[] }; - cells.push(text.plain); const r0 = text.runs[0]; if (firstFontSizePx === undefined && r0?.fontSize) { @@ -1867,16 +1867,33 @@ 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. + // Per-cell fill from . We continue to track + // the first header / body fill as table-level defaults for any + // cell that *doesn't* override (matches the legacy renderer). const cellFill = resolveColor(tc?.["a:tcPr"]?.["a:solidFill"], ctx.theme); if (cellFill) { if (ri === 0 && headerCellFill === undefined) headerCellFill = cellFill; else if (ri > 0 && bodyCellFill === undefined) bodyCellFill = cellFill; } + + // Per-cell alignment from (vertical) or + // the first paragraph's (horizontal). + const algn = txBody?.["a:p"]?.["a:pPr"]?.["@_algn"] + ?? asArray(txBody?.["a:p"])[0]?.["a:pPr"]?.["@_algn"]; + const horiz: TableCell["align"] | undefined = + algn === "ctr" ? "center" : algn === "r" ? "right" : algn === "l" ? "left" : undefined; + + const cell: TableCell = { text: text.plain }; + if (r0?.color) cell.color = r0.color; + if (cellFill) cell.fill = cellFill; + if (r0?.bold) cell.bold = true; + if (r0?.italic) cell.italic = true; + if (r0?.fontSize) { + cell.fontSize = Math.max(8, Math.round(r0.fontSize * ctx.fit.scale)); + } + if (r0?.fontFamily) cell.fontFamily = r0.fontFamily; + if (horiz) cell.align = horiz; + cells.push(cell); } rows.push(cells); } diff --git a/packages/slidewise/src/lib/types.ts b/packages/slidewise/src/lib/types.ts index 2201d01..bd33460 100644 --- a/packages/slidewise/src/lib/types.ts +++ b/packages/slidewise/src/lib/types.ts @@ -248,9 +248,50 @@ export interface LineElement extends BaseElement { glow?: GlowSpec; } +/** + * Per-cell formatting captured from `` (fill, alignment) and the + * first run's `` (color, weight, size, family) when the PPTX + * importer parses a table. + * + * The schema is intentionally flat: PowerPoint tables routinely mix + * per-cell text colors, bold phase-header labels, and per-cell fills, + * and the previous `string[][]` shape collapsed all of that to the + * table's defaults. `text` is the visible content; the optional fields + * override the table-level defaults (`textColor`, `fontSize`, etc.) + * only when they were explicitly authored at the cell level. + * + * Cells provided as a bare string in `TableElement.rows` (legacy decks, + * AI-authored decks that don't carry styling) are accepted and treated + * as `{ text }` — no migration step required. + */ +export interface TableCell { + text: string; + /** Resolved hex colour for this cell's text, overrides `textColor`. */ + color?: string; + /** Resolved hex fill for this cell, overrides any row/header/banded fill. */ + fill?: string; + bold?: boolean; + italic?: boolean; + /** Font size in canvas pixels (post-fit-scaling), overrides `fontSize`. */ + fontSize?: number; + fontFamily?: string; + align?: "left" | "center" | "right"; + /** Horizontal cell merge (cells to the right are absorbed). */ + colSpan?: number; + /** Vertical cell merge (cells below are absorbed). */ + rowSpan?: number; +} + +export type TableRow = (string | TableCell)[]; + export interface TableElement extends BaseElement { type: "table"; - rows: string[][]; + /** + * Each cell is either a plain string (renders with table defaults) or + * a `TableCell` carrying per-cell overrides. Plain strings are kept as + * the lossless representation for AI-authored / simple decks. + */ + rows: TableRow[]; headerFill: string; rowFill: string; textColor: string;