Skip to content

Commit 17c069f

Browse files
feat(pptx): template-faithful serialization, instantiable layouts, connectors (#86)
Fixes for branded-template -> AI deck generation, plus new authoring primitives: - fix(pptx): replay slide background/layout by Slide.sourceSlideIndex, not output position - fix(pptx): deep-copy preserved charts (workbook, colors/style, rels, content types) - fix(pptx): drive output slide size from non-16:9 source; preserve chrome; invert fit - feat(pptx): expose master layouts (Deck.layouts) + addSlideFromLayout() - feat(chart): export buildChartOption/defaultPaletteColor/makeValueFormatter - feat(pptx): first-class connector element (renderer + <p:cxnSp> writer)
1 parent f155641 commit 17c069f

16 files changed

Lines changed: 1893 additions & 127 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@textcortex/slidewise": minor
3+
---
4+
5+
Template-faithful serialization fixes and new authoring primitives for AI deck generation:
6+
7+
- **Per-slide source mapping**`Slide.sourceSlideIndex` lets a host that clones / reorders / subsets imported slides declare which source slide each output slide replays its background and layout from, instead of mapping by output position (fixes blank/white-on-white slides on reordered decks).
8+
- **Deep chart preservation** — preserved charts now carry their full dependency tree (embedded workbook, colors/style parts, rels, content types), so they no longer trigger a PowerPoint repair and keep "Edit Data" + custom styling.
9+
- **Non-16:9 templates** — a source's real slide size now drives the output (4:3, 16:10, custom), preserving its masters / layouts / theme / fonts and inverting the authoring-canvas fit for emitted elements, instead of silently falling back to a generic 16:9 deck.
10+
- **Instantiable layouts**`parsePptx` exposes master layouts on `Deck.layouts`, and the new `addSlideFromLayout(deck, layoutId, opts)` mints a fresh slide bound to a layout with its text placeholders ready to fill.
11+
- **Chart-option helpers**`buildChartOption`, `defaultPaletteColor`, and `makeValueFormatter` are exported so hosts can build the exact ECharts options Slidewise renders (e.g. for server-side previews).
12+
- **Connector primitive** — a first-class `connector` element (straight / bent / curved, arrowheads, flips) with renderer + `<p:cxnSp>` writer support.

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

Lines changed: 93 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ import type {
1111
IconElement,
1212
EmbedElement,
1313
ChartElement,
14+
ConnectorElement,
1415
GroupElement,
1516
UnknownElement,
1617
ShadowSpec,
1718
GlowSpec,
1819
} from "@/lib/types";
20+
import { buildChartOption } from "@/lib/chart/chartOption";
1921

2022
export function ElementView({
2123
el,
@@ -43,6 +45,8 @@ export function ElementView({
4345
return <EmbedView el={el} />;
4446
case "chart":
4547
return <ChartView el={el} />;
48+
case "connector":
49+
return <ConnectorView el={el} />;
4650
case "group":
4751
return <GroupView el={el} editing={editing} onTextCommit={onTextCommit} />;
4852
case "unknown":
@@ -1014,6 +1018,92 @@ function LineView({ el }: { el: LineElement }) {
10141018
);
10151019
}
10161020

1021+
/**
1022+
* Render a connector — a first-class line between two anchor corners of its
1023+
* bounding box. `flipH`/`flipV` pick the diagonal; `kind` selects straight /
1024+
* bent (elbow) / curved geometry; `startArrow`/`endArrow` add arrowheads.
1025+
* Mirrors the `<p:cxnSp>` the writer emits so the editor preview matches save.
1026+
*/
1027+
function ConnectorView({ el }: { el: ConnectorElement }) {
1028+
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
1029+
const w = Math.abs(el.w) || 1;
1030+
const h = Math.abs(el.h) || 1;
1031+
const sx = el.flipH ? w : 0;
1032+
const sy = el.flipV ? h : 0;
1033+
const ex = el.flipH ? 0 : w;
1034+
const ey = el.flipV ? 0 : h;
1035+
1036+
let d: string;
1037+
if (el.kind === "bent") {
1038+
const mx = (sx + ex) / 2;
1039+
d = `M ${sx} ${sy} L ${mx} ${sy} L ${mx} ${ey} L ${ex} ${ey}`;
1040+
} else if (el.kind === "curved") {
1041+
const mx = (sx + ex) / 2;
1042+
d = `M ${sx} ${sy} C ${mx} ${sy} ${mx} ${ey} ${ex} ${ey}`;
1043+
} else {
1044+
d = `M ${sx} ${sy} L ${ex} ${ey}`;
1045+
}
1046+
1047+
const startId = `cxn-s-${uid}`;
1048+
const endId = `cxn-e-${uid}`;
1049+
const hasStart = el.startArrow && el.startArrow !== "none";
1050+
const hasEnd = el.endArrow && el.endArrow !== "none";
1051+
1052+
return (
1053+
<svg
1054+
viewBox={`0 0 ${w} ${h}`}
1055+
preserveAspectRatio="none"
1056+
width="100%"
1057+
height="100%"
1058+
style={{ overflow: "visible", ...effectStyle(el.shadow, el.glow, "filter") }}
1059+
>
1060+
<defs>
1061+
{hasStart && <ArrowMarker id={startId} color={el.stroke} orient="auto-start-reverse" />}
1062+
{hasEnd && <ArrowMarker id={endId} color={el.stroke} orient="auto" />}
1063+
</defs>
1064+
<path
1065+
d={d}
1066+
fill="none"
1067+
stroke={el.stroke}
1068+
strokeWidth={el.strokeWidth}
1069+
strokeDasharray={dashStyleFor(el.dashType, el.strokeWidth).dasharray}
1070+
strokeLinecap="round"
1071+
strokeLinejoin="round"
1072+
vectorEffect="non-scaling-stroke"
1073+
markerStart={hasStart ? `url(#${startId})` : undefined}
1074+
markerEnd={hasEnd ? `url(#${endId})` : undefined}
1075+
/>
1076+
</svg>
1077+
);
1078+
}
1079+
1080+
/** A reusable triangular arrowhead marker. The PPTX writer encodes the exact
1081+
* arrowhead family; the preview uses a single triangular glyph. */
1082+
function ArrowMarker({
1083+
id,
1084+
color,
1085+
orient,
1086+
}: {
1087+
id: string;
1088+
color: string;
1089+
orient: string;
1090+
}) {
1091+
return (
1092+
<marker
1093+
id={id}
1094+
viewBox="0 0 10 10"
1095+
refX="9"
1096+
refY="5"
1097+
markerWidth="7"
1098+
markerHeight="7"
1099+
orient={orient}
1100+
markerUnits="strokeWidth"
1101+
>
1102+
<path d="M 0 0 L 10 5 L 0 10 z" fill={color} />
1103+
</marker>
1104+
);
1105+
}
1106+
10171107
function TableView({ el }: { el: TableElement }) {
10181108
const cols = el.rows[0]?.length ?? 1;
10191109
const rowCount = el.rows.length;
@@ -1315,110 +1405,6 @@ function ChartView({ el }: { el: ChartElement }) {
13151405
return <div ref={ref} style={{ width: "100%", height: "100%" }} />;
13161406
}
13171407

1318-
/**
1319-
* Translate a Slidewise ChartElement into an ECharts option object. Handles
1320-
* bar / column / line / area / pie / doughnut, including stacked + percent
1321-
* stacked variants. Value labels are surfaced when the source deck had
1322-
* `<c:showVal val="1"/>` on at least one series.
1323-
*/
1324-
function buildChartOption(el: ChartElement) {
1325-
const palette = el.series.map((s, i) => s.color ?? defaultPaletteColor(i));
1326-
const isPercent = el.grouping === "percentStacked";
1327-
const valueFormatter = makeValueFormatter(el.valueFormat, isPercent);
1328-
1329-
if (el.kind === "pie" || el.kind === "doughnut") {
1330-
const data = el.categories.map((cat, i) => ({
1331-
name: cat || `Slice ${i + 1}`,
1332-
value: el.series[0]?.values[i] ?? 0,
1333-
}));
1334-
return {
1335-
color: palette,
1336-
title: el.title ? { text: el.title, left: "center", top: 4 } : undefined,
1337-
tooltip: { trigger: "item", valueFormatter },
1338-
legend: { bottom: 4 },
1339-
series: [
1340-
{
1341-
type: "pie",
1342-
radius: el.kind === "doughnut" ? ["45%", "75%"] : "70%",
1343-
data,
1344-
label: el.showDataLabels
1345-
? { formatter: (p: { value: number }) => valueFormatter(p.value) }
1346-
: { show: false },
1347-
},
1348-
],
1349-
};
1350-
}
1351-
1352-
const isHorizontal = el.kind === "bar"; // "column" + everything else: vertical
1353-
const xAxis = isHorizontal
1354-
? { type: "value", axisLabel: { formatter: valueFormatter } }
1355-
: { type: "category", data: el.categories };
1356-
const yAxis = isHorizontal
1357-
? { type: "category", data: el.categories }
1358-
: { type: "value", axisLabel: { formatter: valueFormatter } };
1359-
1360-
const stackKey =
1361-
el.grouping === "stacked" || el.grouping === "percentStacked"
1362-
? "total"
1363-
: undefined;
1364-
1365-
const series = el.series.map((s, i) => {
1366-
const color = s.color ?? defaultPaletteColor(i);
1367-
const base = {
1368-
name: s.name,
1369-
type: el.kind === "line" ? "line" : el.kind === "area" ? "line" : "bar",
1370-
data: s.values.map((v) => (v === null ? 0 : v)),
1371-
// Pin the colour explicitly so ECharts can't reassign via palette
1372-
// cycling when multiple series share the same `name` (PowerPoint
1373-
// decks routinely do this — same label, distinct colour fills).
1374-
itemStyle: { color },
1375-
...(el.kind === "area" ? { areaStyle: { color } } : {}),
1376-
...(el.kind === "line"
1377-
? { lineStyle: { color }, symbol: "circle", symbolSize: 6 }
1378-
: {}),
1379-
...(stackKey ? { stack: stackKey } : {}),
1380-
label: el.showDataLabels
1381-
? {
1382-
show: true,
1383-
position: stackKey ? "inside" : "top",
1384-
formatter: (p: { value: number }) => valueFormatter(p.value),
1385-
fontSize: 11,
1386-
color: stackKey ? "#FFFFFF" : "#111111",
1387-
}
1388-
: { show: false },
1389-
};
1390-
return base;
1391-
});
1392-
1393-
return {
1394-
color: palette,
1395-
title: el.title ? { text: el.title, left: "center", top: 4 } : undefined,
1396-
tooltip: { trigger: "axis", valueFormatter },
1397-
legend: { bottom: 4, type: "scroll" },
1398-
grid: { left: 56, right: 24, top: el.title ? 36 : 16, bottom: 56 },
1399-
xAxis,
1400-
yAxis,
1401-
series,
1402-
};
1403-
}
1404-
1405-
function defaultPaletteColor(i: number): string {
1406-
// Office-ish accent rotation, used when a series omits explicit fill.
1407-
const palette = ["#4F81BD", "#C0504D", "#9BBB59", "#8064A2", "#4BACC6", "#F79646"];
1408-
return palette[i % palette.length];
1409-
}
1410-
1411-
function makeValueFormatter(formatCode: string | undefined, percent: boolean) {
1412-
return (value: number) => {
1413-
if (typeof value !== "number" || !Number.isFinite(value)) return "";
1414-
if (percent) return `${Math.round(value * 100)}%`;
1415-
if (formatCode && formatCode.includes("$")) {
1416-
const decimals = (formatCode.match(/0\.(0+)/)?.[1] ?? "").length;
1417-
return `$${value.toLocaleString(undefined, {
1418-
minimumFractionDigits: decimals,
1419-
maximumFractionDigits: decimals,
1420-
})}`;
1421-
}
1422-
return value.toLocaleString();
1423-
};
1424-
}
1408+
// Chart-option construction (buildChartOption / defaultPaletteColor /
1409+
// makeValueFormatter) lives in @/lib/chart/chartOption so it can be shared with
1410+
// hosts via the public API for server-side previews. ChartView imports it.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, it, expect } from "vitest";
2+
import { renderToStaticMarkup } from "react-dom/server";
3+
import { ElementView } from "../ElementView";
4+
import type { ConnectorElement } from "@/lib/types";
5+
6+
/**
7+
* F3 renderer: the connector element renders to an SVG path with arrowhead
8+
* markers, so it shows up in the editor canvas and the host's static
9+
* renderToString preview (not as an empty box).
10+
*/
11+
12+
const connector: ConnectorElement = {
13+
id: "c1",
14+
type: "connector",
15+
x: 0,
16+
y: 0,
17+
w: 200,
18+
h: 120,
19+
rotation: 0,
20+
z: 1,
21+
kind: "curved",
22+
stroke: "#3366CC",
23+
strokeWidth: 2,
24+
endArrow: "triangle",
25+
};
26+
27+
describe("F3: connector renderer", () => {
28+
it("renders an SVG path with an end arrowhead marker", () => {
29+
const html = renderToStaticMarkup(<ElementView el={connector} />);
30+
expect(html).toContain("<svg");
31+
expect(html).toContain("<path");
32+
// Curved kind → a cubic bezier command in the path data.
33+
expect(html).toContain(" C ");
34+
// End arrow → a marker definition + markerEnd reference.
35+
expect(html).toContain("<marker");
36+
expect(html).toContain("marker-end");
37+
expect(html).toContain("#3366CC");
38+
});
39+
40+
it("omits arrowheads when none are set", () => {
41+
const html = renderToStaticMarkup(
42+
<ElementView el={{ ...connector, kind: "straight", endArrow: "none" }} />
43+
);
44+
expect(html).toContain("<path");
45+
expect(html).not.toContain("<marker");
46+
});
47+
});

packages/slidewise/src/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,28 @@ export type { ParseDiagnostics, ParseResult } from "./lib/pptx/types";
9494
export { migrate, CURRENT_DECK_VERSION } from "./lib/schema/migrate";
9595
export { resolveJsonDeck } from "./lib/schema/json";
9696

97+
/**
98+
* Instantiate a fresh slide from one of the deck's master layouts
99+
* (`Deck.layouts`, populated by `parsePptx`). The unlock for generating decks
100+
* with more slides than the template hand-authored, using the template's own
101+
* layout variety.
102+
*/
103+
export {
104+
addSlideFromLayout,
105+
type AddSlideFromLayoutOptions,
106+
} from "./lib/layouts";
107+
108+
/**
109+
* Chart-option helpers. Build the exact ECharts option Slidewise renders a
110+
* `ChartElement` with — for host-side previews / server-side render-to-image —
111+
* without re-implementing (and drifting from) the package's translation.
112+
*/
113+
export {
114+
buildChartOption,
115+
defaultPaletteColor,
116+
makeValueFormatter,
117+
} from "./lib/chart/chartOption";
118+
97119
export type {
98120
Deck,
99121
Slide,
@@ -115,8 +137,13 @@ export type {
115137
ChartKind,
116138
ChartGrouping,
117139
ChartSeries,
140+
ConnectorElement,
141+
ConnectorKind,
142+
ArrowheadKind,
118143
GroupElement,
119144
UnknownElement,
145+
DeckLayout,
146+
LayoutPlaceholder,
120147
ElementDraft,
121148
ShadowSpec,
122149
GlowSpec,

0 commit comments

Comments
 (0)