Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/table-per-cell-formatting.md
Original file line number Diff line number Diff line change
@@ -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 `<a:tcPr><a:solidFill>` for the cell fill and the first run's `<a:rPr>` for colour / bold / italic / size / typeface, plus `<a:pPr algn="…">` 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.
93 changes: 60 additions & 33 deletions packages/slidewise/src/components/editor/ElementView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ImageElement,
LineElement,
TableElement,
TableCell,
IconElement,
EmbedElement,
ChartElement,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -758,34 +764,55 @@ function TableView({ el }: { el: TableElement }) {
}}
>
{el.rows.flatMap((row, ri) =>
row.map((cell, ci) => (
<div
key={`${ri}-${ci}`}
style={{
background: cellFill(ri, ci),
color: cellColor(ri, ci),
fontSize: el.fontSize,
padding: "12px 16px",
display: "flex",
alignItems: "center",
fontWeight:
(hasHeader && ri === 0) || (el.firstColFill && ci === 0)
? 600
: 400,
boxSizing: "border-box",
minWidth: 0,
minHeight: 0,
overflow: "hidden",
wordBreak: "break-word",
borderRight:
ci < cols - 1 ? `1px solid ${stroke}` : undefined,
borderBottom:
ri < rowCount - 1 ? `1px solid ${stroke}` : undefined,
}}
>
{cell}
</div>
))
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 (
<div
key={`${ri}-${ci}`}
style={{
background: fill,
color,
fontSize,
fontWeight,
fontStyle: cell.italic ? "italic" : undefined,
fontFamily: cell.fontFamily,
textAlign: cell.align,
justifyContent:
cell.align === "center"
? "center"
: cell.align === "right"
? "flex-end"
: undefined,
padding: "12px 16px",
display: "flex",
alignItems: "center",
boxSizing: "border-box",
minWidth: 0,
minHeight: 0,
overflow: "hidden",
wordBreak: "break-word",
borderRight:
ci < cols - 1 ? `1px solid ${stroke}` : undefined,
borderBottom:
ri < rowCount - 1 ? `1px solid ${stroke}` : undefined,
}}
>
{cell.text}
</div>
);
})
)}
</div>
);
Expand Down
2 changes: 2 additions & 0 deletions packages/slidewise/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ export type {
ImageElement,
LineElement,
TableElement,
TableCell,
TableRow,
IconElement,
EmbedElement,
ChartElement,
Expand Down
58 changes: 58 additions & 0 deletions packages/slidewise/src/lib/pptx/__tests__/table-per-cell.test.ts
Original file line number Diff line number Diff line change
@@ -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
* `<a:tcPr><a:solidFill>` and per-cell run formatting in the first run's
* `<a:rPr><a:solidFill>`. 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 <a:tcPr> / <a:rPr>", async () => {
const buf = readFileSync(FIXTURE);

Check failure on line 19 in packages/slidewise/src/lib/pptx/__tests__/table-per-cell.test.ts

View workflow job for this annotation

GitHub Actions / Typecheck, test, build

src/lib/pptx/__tests__/table-per-cell.test.ts > table per-cell formatting > captures per-cell color and fill from <a:tcPr> / <a:rPr>

Error: ENOENT: no such file or directory, open '/home/runner/work/SlideWise/SlideWise/.context/attachments/eon-deck.pptx' ❯ src/lib/pptx/__tests__/table-per-cell.test.ts:19:17 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: '/home/runner/work/SlideWise/SlideWise/.context/attachments/eon-deck.pptx' }
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");
});
});
57 changes: 47 additions & 10 deletions packages/slidewise/src/lib/pptx/deckToPptx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ImageElement,
LineElement,
TableElement,
TableCell,
IconElement,
EmbedElement,
ChartElement,
Expand Down Expand Up @@ -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),
Expand Down
35 changes: 26 additions & 9 deletions packages/slidewise/src/lib/pptx/pptxToDeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
ImageElement,
LineElement,
TableElement,
TableCell,
ChartElement,
ChartSeries,
UnknownElement,
Expand Down Expand Up @@ -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;
Expand All @@ -1849,34 +1850,50 @@ 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) {
firstFontSizePx = Math.max(8, Math.round(r0.fontSize * ctx.fit.scale));
}
if (!firstColor && r0?.color) firstColor = r0.color;

// Cell-level <a:tcPr><a:solidFill> 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 <a:tcPr><a:solidFill>. 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 <a:tcPr anchor="ctr|t|b"> (vertical) or
// the first paragraph's <a:pPr algn="l|ctr|r"> (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);
}
Expand Down
Loading
Loading