Skip to content

Commit a085c8d

Browse files
fix(pptx): import-fidelity fixes for think-cell / brand-template decks (#75)
Broad set of importer fixes surfaced reviewing the Intero master template: hidden-shape skipping, run highlight, cap/letter-case, weight-named font families, per-cell table fills/borders/spans/widths/heights/anchors + rich cell runs, Wingdings bullet glyph mapping, empty-paragraph bullet/blank-line handling + trailing-empty trim, block-arrow paths, lnRef outline colour, text-bearing shape fill/border/radius, spAutoFit no-wrap, and per-line hanging-indent bullets. See .changeset/intero-import-fidelity.md for the full list. Adds 14 importer regression tests (round2/roundtrip).
1 parent b8bc627 commit a085c8d

7 files changed

Lines changed: 1572 additions & 74 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@textcortex/slidewise": patch
3+
---
4+
5+
fix(pptx): import-fidelity fixes for think-cell / brand-template decks
6+
7+
- Skip shapes flagged `hidden="1"` (e.g. think-cell "do not delete" data objects)
8+
- Render run-level text highlight (`<a:rPr><a:highlight>`) end to end
9+
- Apply `cap="all"`/`"small"` (including when inherited from a placeholder list style) as a render-time letter-case transform
10+
- Derive font weight from weight-named families ("Gilroy ExtraBold" → 800, "… Medium" → 500, …) so substitute fonts render at the right heaviness
11+
- 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
12+
- Map Wingdings bullet glyphs to Unicode (`ü`→✓, `q`→☐, `§`→▪, …)
13+
- Bullets: repeat a character bullet across in-paragraph line breaks, suppress the glyph on empty paragraphs, and trim trailing empty paragraphs
14+
- Synthesise block-arrow paths (`down`/`up`/`left`/`rightArrow`) and resolve outline colour from `<p:style><a:lnRef>` so dashed/outlined shapes draw
15+
- Keep a text-bearing preset or custom-geometry shape's fill, border, and corner radius behind its text (roundRect callouts, outlined chevrons)
16+
- Honour `<a:bodyPr><a:spAutoFit>` no-wrap for short single-line labels; skip the arrow-tip text inset on no-fill label shapes
17+
- Render per-paragraph hanging-indent bullets as one block per line so multi-line items align correctly

packages/slidewise/src/components/editor/ElementView.tsx

Lines changed: 176 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
ImageElement,
88
LineElement,
99
TableElement,
10+
CellBorderSide,
1011
IconElement,
1112
EmbedElement,
1213
ChartElement,
@@ -134,10 +135,15 @@ function TextView({
134135
? "center"
135136
: "flex-end",
136137
background: el.background,
138+
// Border / radius for a text-bearing preset shape (e.g. roundRect callout).
139+
border: el.borderColor
140+
? `${el.borderWidth ?? 1}px solid ${el.borderColor}`
141+
: undefined,
142+
borderRadius: el.borderRadius ? el.borderRadius : undefined,
137143
padding: el.padding
138144
? `${el.padding.t}px ${el.padding.r}px ${el.padding.b}px ${el.padding.l}px`
139145
: undefined,
140-
boxSizing: el.padding ? "border-box" : undefined,
146+
boxSizing: el.padding || el.borderColor ? "border-box" : undefined,
141147
cursor: editing ? "text" : "inherit",
142148
};
143149
const inner: React.CSSProperties = {
@@ -154,8 +160,8 @@ function TextView({
154160
textAlign: el.align,
155161
lineHeight: el.lineHeight,
156162
letterSpacing: el.letterSpacing,
157-
whiteSpace: "pre-wrap",
158-
wordBreak: "break-word",
163+
whiteSpace: el.noWrap ? "pre" : "pre-wrap",
164+
wordBreak: el.noWrap ? "normal" : "break-word",
159165
outline: "none",
160166
};
161167

@@ -187,6 +193,9 @@ function TextView({
187193
d={backingPath.d}
188194
fill={backingPaint.paint}
189195
fillRule={backingPath.fillRule ?? "nonzero"}
196+
stroke={backingPath.stroke}
197+
strokeWidth={backingPath.stroke ? backingPath.strokeWidth ?? 1 : undefined}
198+
vectorEffect={backingPath.stroke ? "non-scaling-stroke" : undefined}
190199
/>
191200
</svg>
192201
) : null;
@@ -216,20 +225,38 @@ function TextView({
216225
{backingSvg}
217226
<div style={innerStacked}>
218227
{el.paragraphs.map((pp, pi) => {
228+
// Indent / spacing live on the per-line blocks below; the wrapper
229+
// only carries alignment (inherited by its line children).
219230
const paraStyle: React.CSSProperties = {
220-
paddingLeft: pp.marL ? pp.marL : undefined,
221-
textIndent: pp.indent ? pp.indent : undefined,
222231
textAlign: pp.align ?? undefined,
223-
marginTop: pp.spaceBefore ? pp.spaceBefore : undefined,
224232
};
225-
const content =
233+
// A hanging-indent paragraph needs each line as its own block —
234+
// CSS text-indent only affects a block's first line, so a
235+
// multi-line bulleted paragraph would misalign every bullet after
236+
// the first. Split on "\n" and render one indented block per line.
237+
const lineRuns: TextRun[][] =
226238
pp.runs && pp.runs.length
227-
? pp.runs.map((r, ri) => (
228-
<span key={ri} style={runCssStyle(r)}>
229-
{r.text}
230-
</span>
231-
))
232-
: pp.text;
239+
? splitRunsByNewline(pp.runs)
240+
: (pp.text ?? "").split("\n").map((t) => [{ text: t }]);
241+
const content = lineRuns.map((line, li) => (
242+
<div
243+
key={li}
244+
style={{
245+
paddingLeft: pp.marL ? pp.marL : undefined,
246+
textIndent: pp.indent ? pp.indent : undefined,
247+
marginTop:
248+
li === 0 && pp.spaceBefore ? pp.spaceBefore : undefined,
249+
}}
250+
>
251+
{line.some((r) => r.text.length > 0)
252+
? line.map((r, ri) => (
253+
<span key={ri} style={runCssStyle(r)}>
254+
{r.text}
255+
</span>
256+
))
257+
: /* keep an empty paragraph's line height (blank line) */ " "}
258+
</div>
259+
));
233260
return (
234261
<div key={pi} style={paraStyle}>
235262
{content || " "}
@@ -288,13 +315,37 @@ function withGenericFallback(family: string | undefined): string | undefined {
288315
return `${family}, sans-serif`;
289316
}
290317

318+
/**
319+
* Split a run list into per-line groups at "\n", preserving each run's style.
320+
* Used so a hanging-indent paragraph can render each line as its own block.
321+
*/
322+
function splitRunsByNewline(runs: TextRun[]): TextRun[][] {
323+
const lines: TextRun[][] = [[]];
324+
for (const r of runs) {
325+
const parts = r.text.split("\n");
326+
parts.forEach((part, i) => {
327+
if (i > 0) lines.push([]);
328+
if (part.length) lines[lines.length - 1].push({ ...r, text: part });
329+
});
330+
}
331+
return lines;
332+
}
333+
291334
function runCssStyle(r: TextRun): React.CSSProperties {
292335
const s: React.CSSProperties = {};
293336
if (r.fontFamily) s.fontFamily = withGenericFallback(r.fontFamily);
294337
if (r.fontSize) s.fontSize = r.fontSize;
295338
if (r.fontWeight) s.fontWeight = r.fontWeight;
296339
if (r.color) s.color = r.color;
340+
if (r.highlight) {
341+
s.backgroundColor = r.highlight;
342+
// Keep the highlight painted continuously across wrapped lines.
343+
s.boxDecorationBreak = "clone";
344+
s.WebkitBoxDecorationBreak = "clone";
345+
}
297346
if (r.italic) s.fontStyle = "italic";
347+
if (r.cap === "all") s.textTransform = "uppercase";
348+
else if (r.cap === "small") s.fontVariant = "small-caps";
298349
if (r.letterSpacing != null) s.letterSpacing = r.letterSpacing;
299350
const decoration = [r.underline && "underline", r.strike && "line-through"]
300351
.filter(Boolean)
@@ -389,6 +440,9 @@ function runsToHtml(runs: TextRun[]): string {
389440
if (r.fontSize) props.push(`font-size: ${r.fontSize}px`);
390441
if (r.fontWeight) props.push(`font-weight: ${r.fontWeight}`);
391442
if (r.italic) props.push(`font-style: italic`);
443+
if (r.cap === "all") props.push(`text-transform: uppercase`);
444+
else if (r.cap === "small") props.push(`font-variant: small-caps`);
445+
if (r.highlight) props.push(`background-color: ${r.highlight}`);
392446
if (r.letterSpacing != null) props.push(`letter-spacing: ${r.letterSpacing}px`);
393447
const decoration = [r.underline && "underline", r.strike && "line-through"]
394448
.filter(Boolean)
@@ -417,6 +471,9 @@ function styleToRun(el: HTMLElement, text: string): TextRun {
417471
if (Number.isFinite(w)) r.fontWeight = w;
418472
}
419473
if (s.fontStyle === "italic") r.italic = true;
474+
if (s.textTransform === "uppercase") r.cap = "all";
475+
else if (s.fontVariant === "small-caps") r.cap = "small";
476+
if (s.backgroundColor) r.highlight = s.backgroundColor;
420477
if (s.letterSpacing) {
421478
const ls = parseFloat(s.letterSpacing);
422479
if (Number.isFinite(ls)) r.letterSpacing = ls;
@@ -477,6 +534,8 @@ function sameStyle(a: TextRun, b: TextRun): boolean {
477534
a.italic === b.italic &&
478535
a.underline === b.underline &&
479536
a.strike === b.strike &&
537+
a.highlight === b.highlight &&
538+
a.cap === b.cap &&
480539
a.letterSpacing === b.letterSpacing
481540
);
482541
}
@@ -965,6 +1024,15 @@ function TableView({ el }: { el: TableElement }) {
9651024
const hasHeader = el.hasHeader ?? true;
9661025
const bandRows = el.bandRows ?? false;
9671026
const cellFill = (ri: number, ci: number): string => {
1027+
// An explicit per-cell fill (PPTX <a:tcPr> override) wins over every
1028+
// row-class default — this is what paints think-cell Gantt cells.
1029+
const perCell = el.cellFills?.[ri]?.[ci];
1030+
if (perCell) return perCell;
1031+
// In a per-cell-fill table, a cell with no fill of its own is transparent
1032+
// (the slide shows through). It must NOT fall back to headerFill/rowFill —
1033+
// those were derived from some other cell and would flood unfilled cells
1034+
// with that colour (e.g. a stray cream band turning the whole grid cream).
1035+
if (el.cellFills) return "transparent";
9681036
if (hasHeader && ri === 0) return el.headerFill;
9691037
if (el.lastRowFill && ri === rowCount - 1 && rowCount > 1) return el.lastRowFill;
9701038
if (el.firstColFill && ci === 0) return el.firstColFill;
@@ -978,27 +1046,98 @@ function TableView({ el }: { el: TableElement }) {
9781046
return el.rowFill;
9791047
};
9801048
const cellColor = (ri: number, ci: number): string => {
1049+
const perCell = el.cellTextColors?.[ri]?.[ci];
1050+
if (perCell) return perCell;
9811051
if (hasHeader && ri === 0 && el.headerTextColor) return el.headerTextColor;
9821052
if (el.firstColTextColor && ci === 0 && !(hasHeader && ri === 0)) {
9831053
return el.firstColTextColor;
9841054
}
9851055
return el.textColor;
9861056
};
1057+
1058+
// When the source defined per-cell borders, honour them exactly: most PPTX
1059+
// (think-cell) cells leave sides blank, so a uniform grid is wrong. Each
1060+
// internal edge is drawn once — by the cell above (its bottom) or to the
1061+
// left (its right) — and a coloured side wins over a neighbour's blank one,
1062+
// so shared edges never double up.
1063+
const hasCellBorders = !!el.cellBorders;
1064+
const sideCss = (s: CellBorderSide | null | undefined): string | undefined =>
1065+
s ? `${s.width}px solid ${s.color}` : undefined;
1066+
// Pick the drawn line between two adjacent sides (a colour beats null/absent).
1067+
const mergeSide = (
1068+
a: CellBorderSide | null | undefined,
1069+
b: CellBorderSide | null | undefined
1070+
): CellBorderSide | null | undefined => a ?? b;
1071+
// Merged cells: a covered continuation cell renders nothing, and a spanning
1072+
// origin cell is placed explicitly so it covers the columns/rows it merges
1073+
// (e.g. a full-width band). Explicit placement (col/row = array index) avoids
1074+
// auto-flow ambiguity once some cells span and others are omitted.
1075+
const hasSpans = !!el.cellSpans;
1076+
const cellPlacement = (ri: number, ci: number): React.CSSProperties => {
1077+
if (!hasSpans) return {};
1078+
const span = el.cellSpans?.[ri]?.[ci];
1079+
return {
1080+
gridColumn: `${ci + 1} / span ${span?.colSpan ?? 1}`,
1081+
gridRow: `${ri + 1} / span ${span?.rowSpan ?? 1}`,
1082+
};
1083+
};
1084+
const cellBorderStyle = (ri: number, ci: number): React.CSSProperties => {
1085+
if (!hasCellBorders) {
1086+
// Legacy default: a single faint grid line shared between cells.
1087+
return {
1088+
borderRight: ci < cols - 1 ? `1px solid ${stroke}` : undefined,
1089+
borderBottom: ri < rowCount - 1 ? `1px solid ${stroke}` : undefined,
1090+
};
1091+
}
1092+
const cb = el.cellBorders?.[ri]?.[ci] ?? undefined;
1093+
const right = mergeSide(cb?.r, el.cellBorders?.[ri]?.[ci + 1]?.l);
1094+
const bottom = mergeSide(cb?.b, el.cellBorders?.[ri + 1]?.[ci]?.t);
1095+
return {
1096+
// Outer top/left edges belong to the first row/column; internal top/left
1097+
// edges are covered by the neighbour's bottom/right so they aren't doubled.
1098+
borderTop: ri === 0 ? sideCss(cb?.t) : undefined,
1099+
borderLeft: ci === 0 ? sideCss(cb?.l) : undefined,
1100+
borderRight: sideCss(right),
1101+
borderBottom: sideCss(bottom),
1102+
};
1103+
};
9871104
return (
9881105
<div
9891106
style={{
9901107
display: "grid",
991-
gridTemplateColumns: `repeat(${cols}, 1fr)`,
992-
gridAutoRows: "1fr",
1108+
gridTemplateColumns:
1109+
el.colWidths && el.colWidths.length === cols
1110+
? el.colWidths.map((w) => `${w}fr`).join(" ")
1111+
: `repeat(${cols}, 1fr)`,
1112+
gridTemplateRows:
1113+
el.rowHeights && el.rowHeights.length === rowCount
1114+
? el.rowHeights.map((h) => `${h}fr`).join(" ")
1115+
: `repeat(${rowCount}, 1fr)`,
9931116
width: "100%",
9941117
height: "100%",
9951118
gap: 0,
9961119
background: "transparent",
997-
boxShadow: `inset 0 0 0 1px ${stroke}`,
1120+
// The legacy frame only applies to tables without explicit cell borders.
1121+
boxShadow: hasCellBorders ? undefined : `inset 0 0 0 1px ${stroke}`,
9981122
}}
9991123
>
10001124
{el.rows.flatMap((row, ri) =>
1001-
row.map((cell, ci) => (
1125+
row.map((cell, ci) => {
1126+
// Cells merged into a neighbour aren't rendered — the spanning
1127+
// origin covers their grid slot.
1128+
if (el.cellSpans?.[ri]?.[ci]?.covered) return null;
1129+
// Rich runs (highlight / per-run font / bullet line breaks / ✓
1130+
// glyphs) take over from the flat string when present.
1131+
const runs = el.cellRuns?.[ri]?.[ci];
1132+
const content =
1133+
runs && runs.length
1134+
? runs.map((r, i) => (
1135+
<span key={i} style={runCssStyle(r)}>
1136+
{r.text}
1137+
</span>
1138+
))
1139+
: cell;
1140+
return (
10021141
<div
10031142
key={`${ri}-${ci}`}
10041143
style={{
@@ -1007,7 +1146,16 @@ function TableView({ el }: { el: TableElement }) {
10071146
fontSize: el.fontSize,
10081147
padding: "12px 16px",
10091148
display: "flex",
1010-
alignItems: "center",
1149+
// Vertical alignment: honour the cell's own anchor when the
1150+
// source set one (<a:tcPr anchor>); otherwise fall back to the
1151+
// header-centred / body-top default. PPTX cells default to top.
1152+
alignItems: (() => {
1153+
const va = el.cellVAligns?.[ri]?.[ci];
1154+
if (va === "middle") return "center";
1155+
if (va === "bottom") return "flex-end";
1156+
if (va === "top") return "flex-start";
1157+
return hasHeader && ri === 0 ? "center" : "flex-start";
1158+
})(),
10111159
fontWeight:
10121160
(hasHeader && ri === 0) || (el.firstColFill && ci === 0)
10131161
? 600
@@ -1017,15 +1165,19 @@ function TableView({ el }: { el: TableElement }) {
10171165
minHeight: 0,
10181166
overflow: "hidden",
10191167
wordBreak: "break-word",
1020-
borderRight:
1021-
ci < cols - 1 ? `1px solid ${stroke}` : undefined,
1022-
borderBottom:
1023-
ri < rowCount - 1 ? `1px solid ${stroke}` : undefined,
1168+
...cellPlacement(ri, ci),
1169+
...cellBorderStyle(ri, ci),
10241170
}}
10251171
>
1026-
{cell}
1172+
{/* Single inline-flow child so run spans wrap as text and the
1173+
"\n" bullet breaks apply (flex children would lay out in a
1174+
row, collapsing every bullet onto one line). */}
1175+
<div style={{ width: "100%", whiteSpace: "pre-wrap" }}>
1176+
{content}
1177+
</div>
10271178
</div>
1028-
))
1179+
);
1180+
})
10291181
)}
10301182
</div>
10311183
);

0 commit comments

Comments
 (0)