ptxiagram 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/routes.mjs ADDED
@@ -0,0 +1,112 @@
1
+ import { FONT } from "./shapes.mjs";
2
+
3
+ export function centerTop(n) { return { x: n.x + n.w / 2, y: n.y }; }
4
+ export function centerBottom(n) { return { x: n.x + n.w / 2, y: n.y + n.h }; }
5
+ export function centerLeft(n) { return { x: n.x, y: n.y + n.h / 2 }; }
6
+ export function centerRight(n) { return { x: n.x + n.w, y: n.y + n.h / 2 }; }
7
+
8
+ // Normalizes a point-to-point line into a {x,y,w,h} box (pptxgenjs "line"
9
+ // shapes are a bounding box + direction, so a purely vertical or horizontal
10
+ // segment always has w=0 or h=0 here).
11
+ function boxFromPoints(p1, p2) {
12
+ return { x: p1.x, y: p1.y, w: p2.x - p1.x, h: p2.y - p1.y };
13
+ }
14
+
15
+ /**
16
+ * Pure geometry: returns the ordered line segments (each an arrow-less
17
+ * {x,y,w,h,arrow} box, `arrow: true` only on the final one) and the label
18
+ * anchor a connector between `from` and `to` would use, without touching a
19
+ * slide. The renderer draws these; the validator walks them to check for
20
+ * crossings against unrelated nodes. See routes' JSDoc in `connector()`
21
+ * below for what each route means.
22
+ */
23
+ export function computeSegments(from, to, opts = {}) {
24
+ const { route = "straight", bias = 0.5, clearY, label } = opts;
25
+
26
+ if (route === "straight") {
27
+ const a = centerRight(from), b = centerLeft(to);
28
+ return {
29
+ segments: [{ ...boxFromPoints(a, b), arrow: true }],
30
+ labelAnchor: label ? { x: a.x, y: a.y, w: b.x - a.x } : null,
31
+ };
32
+ }
33
+
34
+ if (route === "drop") {
35
+ const a = centerBottom(from), b = centerTop(to);
36
+ if (Math.abs(a.x - b.x) < 0.05) {
37
+ return {
38
+ segments: [{ ...boxFromPoints(a, { x: a.x, y: b.y }), arrow: true }],
39
+ labelAnchor: label ? { x: a.x - 0.6, y: (a.y + b.y) / 2, w: 1.2 } : null,
40
+ };
41
+ }
42
+ const midY = a.y + (b.y - a.y) * bias;
43
+ return {
44
+ segments: [
45
+ { ...boxFromPoints(a, { x: a.x, y: midY }), arrow: false },
46
+ { ...boxFromPoints({ x: Math.min(a.x, b.x), y: midY }, { x: Math.max(a.x, b.x), y: midY }), arrow: false },
47
+ { ...boxFromPoints({ x: b.x, y: midY }, b), arrow: true },
48
+ ],
49
+ labelAnchor: label ? { x: Math.min(a.x, b.x) - 0.6, y: midY, w: Math.abs(b.x - a.x) + 1.2 } : null,
50
+ };
51
+ }
52
+
53
+ if (route === "elbow-right") {
54
+ const a = centerRight(from), b = centerLeft(to);
55
+ if (Math.abs(a.y - b.y) < 0.05) {
56
+ return {
57
+ segments: [{ ...boxFromPoints(a, { x: b.x, y: a.y }), arrow: true }],
58
+ labelAnchor: label ? { x: a.x, y: a.y, w: b.x - a.x } : null,
59
+ };
60
+ }
61
+ const midX = a.x + (b.x - a.x) * bias;
62
+ return {
63
+ segments: [
64
+ { ...boxFromPoints(a, { x: midX, y: a.y }), arrow: false },
65
+ { ...boxFromPoints({ x: midX, y: Math.min(a.y, b.y) }, { x: midX, y: Math.max(a.y, b.y) }), arrow: false },
66
+ { ...boxFromPoints({ x: midX, y: b.y }, b), arrow: true },
67
+ ],
68
+ labelAnchor: label ? { x: midX - 0.6, y: b.y, w: 1.2 } : null,
69
+ };
70
+ }
71
+
72
+ if (route === "arc-over-top") {
73
+ if (clearY === undefined) throw new Error("route 'arc-over-top' requires opts.clearY");
74
+ const a = centerTop(from), b = centerTop(to);
75
+ return {
76
+ segments: [
77
+ { ...boxFromPoints(a, { x: a.x, y: clearY }), arrow: false },
78
+ { ...boxFromPoints({ x: Math.min(a.x, b.x), y: clearY }, { x: Math.max(a.x, b.x), y: clearY }), arrow: false },
79
+ { ...boxFromPoints({ x: b.x, y: clearY }, b), arrow: true },
80
+ ],
81
+ labelAnchor: label ? { x: Math.min(a.x, b.x), y: clearY, w: Math.abs(b.x - a.x) } : null,
82
+ };
83
+ }
84
+
85
+ throw new Error(`Unknown route: ${route}`);
86
+ }
87
+
88
+ /** Draws a connector between two node boxes {x,y,w,h}. See computeSegments
89
+ * for what each `route` means; this is the thin drawing layer over it.
90
+ * opts: { color, dashed, label, route, bias, clearY } */
91
+ export function connector(slide, from, to, opts = {}) {
92
+ const { color = "94A3B8", dashed = false, label } = opts;
93
+ const { segments, labelAnchor } = computeSegments(from, to, opts);
94
+
95
+ for (const s of segments) {
96
+ slide.addShape("line", {
97
+ x: s.x, y: s.y, w: s.w, h: s.h,
98
+ line: {
99
+ color, width: 2,
100
+ dashType: dashed ? "dash" : "solid",
101
+ ...(s.arrow ? { endArrowType: "triangle" } : {}),
102
+ },
103
+ });
104
+ }
105
+
106
+ if (label && labelAnchor) {
107
+ slide.addText(label, {
108
+ x: labelAnchor.x, y: labelAnchor.y - 0.32, w: labelAnchor.w, h: 0.28,
109
+ align: "center", fontFace: FONT, fontSize: 8.5, color: "475569",
110
+ });
111
+ }
112
+ }
package/lib/shapes.mjs ADDED
@@ -0,0 +1,107 @@
1
+ // Semantic node types -> native PPTX shape preset + color pair.
2
+ // Shape preset strings are raw OOXML preset geometry names (pptxgenjs
3
+ // accepts these directly, no need to import the ShapeType enum).
4
+ export const NODE_TYPES = {
5
+ client: { shape: "ellipse", fill: "F1F5F9", stroke: "94A3B8" },
6
+ frontend: { shape: "rect", fill: "E2E8F0", stroke: "94A3B8" },
7
+ neutral: { shape: "roundRect", fill: "E2E8F0", stroke: "94A3B8" },
8
+ backend: { shape: "roundRect", fill: "D1FAE5", stroke: "10B981" },
9
+ database: { shape: "can", fill: "DBEAFE", stroke: "3B82F6" },
10
+ cache: { shape: "hexagon", fill: "DBEAFE", stroke: "3B82F6" },
11
+ queue: { shape: "cube", fill: "DBEAFE", stroke: "3B82F6" },
12
+ external: { shape: "cloud", fill: "FEF3C7", stroke: "F59E0B" },
13
+ security: { shape: "roundRect", fill: "FFE4E6", stroke: "F43F5E" },
14
+ decision: { shape: "diamond", fill: "FFE4E6", stroke: "F43F5E" },
15
+ };
16
+
17
+ export const FONT = "맑은 고딕";
18
+
19
+ // Edge/connection variant -> color + dash pair, so JSON authors pick a
20
+ // semantic variant instead of guessing hex codes.
21
+ export const EDGE_VARIANTS = {
22
+ default: { color: "94A3B8", dashed: false },
23
+ emphasis: { color: "10B981", dashed: false },
24
+ security: { color: "F43F5E", dashed: true },
25
+ dashed: { color: "64748B", dashed: true },
26
+ };
27
+
28
+ // Draws a labeled node and returns its box {x,y,w,h} for connector anchoring.
29
+ export function drawNode(slide, { x, y, w = 1.8, h = 0.95, label, sublabel, type = "neutral", tag }) {
30
+ const style = NODE_TYPES[type] || NODE_TYPES.neutral;
31
+ slide.addShape(style.shape, {
32
+ x, y, w, h,
33
+ fill: { color: style.fill },
34
+ line: { color: style.stroke, width: 1.5 },
35
+ shadow: { type: "outer", color: "1E293B", opacity: 0.18, blur: 4, offset: 1, angle: 90 },
36
+ });
37
+
38
+ const hasSub = Boolean(sublabel);
39
+ slide.addText(label, {
40
+ x, y: y + h / 2 - (hasSub ? 0.28 : 0.14), w, h: 0.32,
41
+ align: "center", valign: "middle", fontFace: FONT, fontSize: 12.5, bold: true, color: "0F172A",
42
+ });
43
+ if (hasSub) {
44
+ slide.addText(sublabel, {
45
+ x, y: y + h / 2 + 0.08, w, h: 0.3,
46
+ align: "center", fontFace: FONT, fontSize: 9, color: "64748B",
47
+ });
48
+ }
49
+ if (tag) {
50
+ slide.addText(tag, {
51
+ x, y: y + h - 0.24, w, h: 0.22,
52
+ align: "center", fontFace: FONT, fontSize: 7.5, color: style.stroke,
53
+ });
54
+ }
55
+ return { x, y, w, h };
56
+ }
57
+
58
+ // Draws a dashed boundary frame (precomputed by lib/layout.mjs's
59
+ // computeArchitectureLayout, which owns the padding math) with a label in
60
+ // its top-left corner. Mirrors archify's `wraps` boundary concept.
61
+ export function drawBoundary(slide, { box, label, color = "F59E0B" }) {
62
+ slide.addShape("rect", {
63
+ ...box,
64
+ fill: { type: "none" },
65
+ line: { color, width: 1.25, dashType: "dash" },
66
+ });
67
+ slide.addText(label, {
68
+ x: box.x + 0.15, y: box.y + 0.1, w: box.w - 0.3, h: 0.3,
69
+ fontFace: FONT, fontSize: 10, color, bold: true,
70
+ });
71
+ }
72
+
73
+ // Draws a row of summary cards (title + bullet items) spanning the slide
74
+ // width, starting at the given y. Used for footnotes/exception details that
75
+ // would otherwise need extra nodes and crossing arrows.
76
+ export function drawCards(slide, cards, y) {
77
+ const totalW = 12.5, gap = 0.25;
78
+ const w = (totalW - gap * (cards.length - 1)) / cards.length;
79
+ cards.forEach((card, i) => {
80
+ const x = 0.4 + i * (w + gap);
81
+ const color = card.color ? `#${card.color}` : "3B82F6";
82
+ slide.addText(card.title, {
83
+ x, y, w, h: 0.3,
84
+ fontFace: FONT, fontSize: 11, bold: true, color: "0F172A",
85
+ });
86
+ const bulletText = card.items.map((t) => `• ${t}`).join("\n");
87
+ slide.addText(bulletText, {
88
+ x, y: y + 0.35, w, h: 1.3,
89
+ fontFace: FONT, fontSize: 9, color: "475569", valign: "top", lineSpacing: 14,
90
+ });
91
+ slide.addShape("rect", { x, y: y + 0.02, w: 0.05, h: 0.22, fill: { color: card.color || "3B82F6" }, line: { type: "none" } });
92
+ });
93
+ }
94
+
95
+ // Draws a swimlane frame with a title strip. Returns the lane's box.
96
+ export function drawLane(slide, { x, y, w, h, label }) {
97
+ slide.addShape("rect", {
98
+ x, y, w, h,
99
+ fill: { color: "F8FAFC" },
100
+ line: { color: "CBD5E1", width: 1, dashType: "dash" },
101
+ });
102
+ slide.addText(label, {
103
+ x: x + 0.15, y: y + 0.05, w: 3, h: 0.3,
104
+ fontFace: FONT, fontSize: 11, color: "64748B", bold: true,
105
+ });
106
+ return { x, y, w, h };
107
+ }
@@ -0,0 +1,143 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import Ajv2020 from "ajv/dist/2020.js";
5
+ import { computeWorkflowLayout, computeArchitectureLayout, SLIDE_W, SLIDE_H } from "./layout.mjs";
6
+ import { computeSegments } from "./routes.mjs";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const SCHEMAS_DIR = path.join(__dirname, "..", "schemas");
10
+
11
+ let ajvInstance;
12
+ async function getAjv() {
13
+ if (ajvInstance) return ajvInstance;
14
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
15
+ const common = JSON.parse(await fs.readFile(path.join(SCHEMAS_DIR, "common.schema.json"), "utf8"));
16
+ ajv.addSchema(common, "common.schema.json");
17
+ ajvInstance = ajv;
18
+ return ajv;
19
+ }
20
+
21
+ /** Validates `data` against schemas/<type>.schema.json. Throws with a
22
+ * readable message (path + reason per error) if invalid. */
23
+ export async function validateSchema(type, data) {
24
+ const ajv = await getAjv();
25
+ const schemaPath = path.join(SCHEMAS_DIR, `${type}.schema.json`);
26
+ const schema = JSON.parse(await fs.readFile(schemaPath, "utf8"));
27
+ const validateFn = ajv.getSchema(schema.$id) || ajv.compile(schema);
28
+ const ok = validateFn(data);
29
+ if (!ok) {
30
+ const messages = validateFn.errors.map((e) => `${e.instancePath || "/"} ${e.message}`);
31
+ throw new Error(`Schema validation failed:\n- ${messages.join("\n- ")}`);
32
+ }
33
+ return true;
34
+ }
35
+
36
+ function rectsOverlap(a, b) {
37
+ return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
38
+ }
39
+
40
+ const CJK_RE = /[ㄱ-힝一-鿿]/;
41
+
42
+ // Crude CJK-aware text width estimate (inches) for a given font size (pt).
43
+ // Not exact glyph metrics -- good enough to flag labels that will visibly
44
+ // overflow their shape, which is the failure mode this exists to catch.
45
+ function estimateTextWidth(text, fontSizePt) {
46
+ const unit = (fontSizePt / 72) * 0.62;
47
+ let units = 0;
48
+ for (const ch of text) units += CJK_RE.test(ch) ? 1.0 : 0.55;
49
+ return units * unit;
50
+ }
51
+
52
+ // All routes in lib/routes.mjs only ever emit axis-aligned segments, so
53
+ // treating each as a simple bounding box is exact, not an approximation.
54
+ function segmentIntersectsRect(seg, rect, epsilon = 0.02) {
55
+ const x1 = Math.min(seg.x, seg.x + seg.w), x2 = Math.max(seg.x, seg.x + seg.w);
56
+ const y1 = Math.min(seg.y, seg.y + seg.h), y2 = Math.max(seg.y, seg.y + seg.h);
57
+ return (
58
+ x1 < rect.x + rect.w - epsilon && x2 > rect.x + epsilon &&
59
+ y1 < rect.y + rect.h - epsilon && y2 > rect.y + epsilon
60
+ );
61
+ }
62
+
63
+ function offSlide(box) {
64
+ return box.x < 0 || box.y < 0 || box.x + box.w > SLIDE_W || box.y + box.h > SLIDE_H;
65
+ }
66
+
67
+ function checkCommon({ problems, ids, boxes, labelOf, fontSizePt, edgesOrConns, resolveEndpoints, edgeLabel }) {
68
+ for (const id of ids) {
69
+ const box = boxes[id];
70
+ const label = labelOf(id);
71
+ const w = estimateTextWidth(label, fontSizePt);
72
+ if (w > box.w - 0.1) {
73
+ problems.push(`"${id}" label "${label}" (~${w.toFixed(2)}in) is wider than its shape (${box.w.toFixed(2)}in) — shorten the label or move detail to sublabel.`);
74
+ }
75
+ if (offSlide(box)) {
76
+ problems.push(`"${id}" is off-slide (slide is ${SLIDE_W}x${SLIDE_H}in).`);
77
+ }
78
+ }
79
+
80
+ for (let i = 0; i < ids.length; i++) {
81
+ for (let j = i + 1; j < ids.length; j++) {
82
+ if (rectsOverlap(boxes[ids[i]], boxes[ids[j]])) {
83
+ problems.push(`"${ids[i]}" and "${ids[j]}" overlap.`);
84
+ }
85
+ }
86
+ }
87
+
88
+ for (const e of edgesOrConns) {
89
+ const { from, to } = resolveEndpoints(e);
90
+ if (!from || !to) continue; // unknown-id errors surface from the renderer/schema instead
91
+ const { segments } = computeSegments(from, to, {
92
+ route: e.route || "straight", bias: e.bias, clearY: e.clearY, label: e.label,
93
+ });
94
+ for (const id of ids) {
95
+ if (id === e.from || id === e.to) continue;
96
+ const rect = boxes[id];
97
+ if (segments.some((seg) => segmentIntersectsRect(seg, rect))) {
98
+ problems.push(`${edgeLabel(e)} crosses "${id}" — pick a different route/bias/clearY or reposition.`);
99
+ }
100
+ }
101
+ }
102
+
103
+ return problems;
104
+ }
105
+
106
+ export function validateWorkflowLayout(raw) {
107
+ const { nodeBoxes } = computeWorkflowLayout(raw);
108
+ const labelOf = (id) => raw.nodes.find((n) => n.id === id).label;
109
+ return checkCommon({
110
+ problems: [],
111
+ ids: Object.keys(nodeBoxes),
112
+ boxes: nodeBoxes,
113
+ labelOf,
114
+ fontSizePt: 12.5,
115
+ edgesOrConns: raw.edges,
116
+ resolveEndpoints: (e) => ({ from: nodeBoxes[e.from], to: nodeBoxes[e.to] }),
117
+ edgeLabel: (e) => `Edge "${e.from}"->"${e.to}"`,
118
+ });
119
+ }
120
+
121
+ export function validateArchitectureLayout(raw) {
122
+ const { boxes } = computeArchitectureLayout(raw);
123
+ const labelOf = (id) => raw.components.find((c) => c.id === id).label;
124
+ return checkCommon({
125
+ problems: [],
126
+ ids: Object.keys(boxes),
127
+ boxes,
128
+ labelOf,
129
+ fontSizePt: 12.5,
130
+ edgesOrConns: raw.connections,
131
+ resolveEndpoints: (e) => ({ from: boxes[e.from], to: boxes[e.to] }),
132
+ edgeLabel: (e) => `Connection "${e.from}"->"${e.to}"`,
133
+ });
134
+ }
135
+
136
+ /** Runs schema validation, then layout collision checks. Returns the list
137
+ * of layout problems (empty = clean); throws if schema validation fails. */
138
+ export async function validateAll(type, raw) {
139
+ await validateSchema(type, raw);
140
+ if (type === "workflow") return validateWorkflowLayout(raw);
141
+ if (type === "architecture") return validateArchitectureLayout(raw);
142
+ throw new Error(`Unknown diagram type: ${type}`);
143
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "ptxiagram",
3
+ "version": "0.1.0",
4
+ "description": "Generate native, editable PowerPoint (PPTX) architecture and workflow diagrams from a small JSON file — real shapes and connectors, not an embedded image. Ships with a Claude Skill (SKILL.md) and a CLI.",
5
+ "type": "module",
6
+ "main": "index.mjs",
7
+ "bin": {
8
+ "ptxiagram": "bin/cli.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib",
13
+ "renderers",
14
+ "schemas",
15
+ "examples",
16
+ "index.mjs",
17
+ "SKILL.md"
18
+ ],
19
+ "scripts": {
20
+ "doctor": "node bin/cli.mjs doctor",
21
+ "test": "node test/render-workflow.test.mjs && node test/render-architecture.test.mjs && node test/validate.test.mjs && node test/workflow-lib-smoke.test.mjs"
22
+ },
23
+ "keywords": [
24
+ "pptx",
25
+ "powerpoint",
26
+ "diagram",
27
+ "claude-skill",
28
+ "architecture-diagram",
29
+ "workflow-diagram",
30
+ "hancom",
31
+ "hanshow",
32
+ "office-automation"
33
+ ],
34
+ "author": "hiio420official",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/hiio420official/ptxiagram.git"
39
+ },
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "dependencies": {
44
+ "ajv": "^8.20.0",
45
+ "jszip": "^3.10.1",
46
+ "pptxgenjs": "^4.0.1"
47
+ }
48
+ }
@@ -0,0 +1,68 @@
1
+ import pptxgen from "pptxgenjs";
2
+ import fs from "node:fs/promises";
3
+ import { drawNode, drawBoundary, drawCards, FONT, EDGE_VARIANTS } from "../../lib/shapes.mjs";
4
+ import { connector } from "../../lib/routes.mjs";
5
+ import { repairPptx } from "../../lib/repair-pptx.mjs";
6
+ import { validateSchema, validateArchitectureLayout } from "../../lib/validate.mjs";
7
+ import { computeArchitectureLayout } from "../../lib/layout.mjs";
8
+
9
+ export async function renderArchitecture(inputPath, outputPath) {
10
+ const raw = JSON.parse(await fs.readFile(inputPath, "utf8"));
11
+ await validateSchema("architecture", raw);
12
+
13
+ const { boxes, boundaryBoxes } = computeArchitectureLayout(raw);
14
+ const problems = validateArchitectureLayout(raw);
15
+ if (problems.length) {
16
+ throw new Error(`Architecture layout validation failed:\n- ${problems.join("\n- ")}`);
17
+ }
18
+
19
+ const pptx = new pptxgen();
20
+ pptx.defineLayout({ name: "WIDE", width: 13.33, height: 7.5 });
21
+ pptx.layout = "WIDE";
22
+ const slide = pptx.addSlide();
23
+
24
+ slide.addText(raw.meta.title, {
25
+ x: 0.4, y: 0.2, w: 12.5, h: 0.5,
26
+ fontFace: FONT, fontSize: 20, bold: true, color: "0F172A",
27
+ });
28
+ if (raw.meta.subtitle) {
29
+ slide.addText(raw.meta.subtitle, {
30
+ x: 0.4, y: 0.65, w: 12.5, h: 0.3,
31
+ fontFace: FONT, fontSize: 11, color: "64748B",
32
+ });
33
+ }
34
+
35
+ // boundaries first so they sit visually under the components they frame
36
+ for (const b of boundaryBoxes) {
37
+ drawBoundary(slide, { box: b.box, label: b.label, ...(b.color ? { color: b.color } : {}) });
38
+ }
39
+
40
+ for (const c of raw.components) {
41
+ drawNode(slide, { ...boxes[c.id], label: c.label, sublabel: c.sublabel, type: c.type, tag: c.tag });
42
+ }
43
+
44
+ for (const conn of raw.connections) {
45
+ const variant = EDGE_VARIANTS[conn.variant || "default"];
46
+ const from = boxes[conn.from], to = boxes[conn.to];
47
+ if (!from) throw new Error(`Connection references unknown component "${conn.from}"`);
48
+ if (!to) throw new Error(`Connection references unknown component "${conn.to}"`);
49
+ connector(slide, from, to, {
50
+ color: variant.color, dashed: variant.dashed, label: conn.label,
51
+ route: conn.route, bias: conn.bias, clearY: conn.clearY,
52
+ });
53
+ }
54
+
55
+ if (raw.cards && raw.cards.length) {
56
+ drawCards(slide, raw.cards, 6.0);
57
+ }
58
+
59
+ slide.addText("Made with a Claude Skill · native PPTX shapes, editable in PowerPoint", {
60
+ x: 0.4, y: 7.15, w: 12.5, h: 0.3,
61
+ fontFace: FONT, fontSize: 8, color: "94A3B8", italic: true,
62
+ });
63
+
64
+ const outFile = outputPath || raw.meta.output || "output.pptx";
65
+ await pptx.writeFile({ fileName: outFile });
66
+ await repairPptx(outFile);
67
+ return outFile;
68
+ }
@@ -0,0 +1,67 @@
1
+ import pptxgen from "pptxgenjs";
2
+ import fs from "node:fs/promises";
3
+ import { drawNode, drawLane, drawCards, FONT, EDGE_VARIANTS } from "../../lib/shapes.mjs";
4
+ import { connector } from "../../lib/routes.mjs";
5
+ import { repairPptx } from "../../lib/repair-pptx.mjs";
6
+ import { validateSchema, validateWorkflowLayout } from "../../lib/validate.mjs";
7
+ import { computeWorkflowLayout } from "../../lib/layout.mjs";
8
+
9
+ export async function renderWorkflow(inputPath, outputPath) {
10
+ const raw = JSON.parse(await fs.readFile(inputPath, "utf8"));
11
+ await validateSchema("workflow", raw);
12
+
13
+ const { laneBoxes, nodeBoxes, cardsY } = computeWorkflowLayout(raw);
14
+ const problems = validateWorkflowLayout(raw);
15
+ if (problems.length) {
16
+ throw new Error(`Workflow layout validation failed:\n- ${problems.join("\n- ")}`);
17
+ }
18
+
19
+ const pptx = new pptxgen();
20
+ pptx.defineLayout({ name: "WIDE", width: 13.33, height: 7.5 });
21
+ pptx.layout = "WIDE";
22
+ const slide = pptx.addSlide();
23
+
24
+ slide.addText(raw.meta.title, {
25
+ x: 0.4, y: 0.2, w: 12.5, h: 0.5,
26
+ fontFace: FONT, fontSize: 20, bold: true, color: "0F172A",
27
+ });
28
+ if (raw.meta.subtitle) {
29
+ slide.addText(raw.meta.subtitle, {
30
+ x: 0.4, y: 0.65, w: 12.5, h: 0.3,
31
+ fontFace: FONT, fontSize: 11, color: "64748B",
32
+ });
33
+ }
34
+
35
+ for (const lane of raw.lanes) {
36
+ drawLane(slide, { ...laneBoxes[lane.id], label: lane.label });
37
+ }
38
+
39
+ for (const n of raw.nodes) {
40
+ drawNode(slide, { ...nodeBoxes[n.id], label: n.label, sublabel: n.sublabel, type: n.type, tag: n.tag });
41
+ }
42
+
43
+ for (const e of raw.edges) {
44
+ const variant = EDGE_VARIANTS[e.variant || "default"];
45
+ const from = nodeBoxes[e.from], to = nodeBoxes[e.to];
46
+ if (!from) throw new Error(`Edge references unknown node "${e.from}"`);
47
+ if (!to) throw new Error(`Edge references unknown node "${e.to}"`);
48
+ connector(slide, from, to, {
49
+ color: variant.color, dashed: variant.dashed,
50
+ label: e.label, route: e.route || "straight", bias: e.bias,
51
+ });
52
+ }
53
+
54
+ if (raw.cards && raw.cards.length) {
55
+ drawCards(slide, raw.cards, cardsY);
56
+ }
57
+
58
+ slide.addText("Made with a Claude Skill · native PPTX shapes, editable in PowerPoint", {
59
+ x: 0.4, y: 7.15, w: 12.5, h: 0.3,
60
+ fontFace: FONT, fontSize: 8, color: "94A3B8", italic: true,
61
+ });
62
+
63
+ const outFile = outputPath || raw.meta.output || "output.pptx";
64
+ await pptx.writeFile({ fileName: outFile });
65
+ await repairPptx(outFile);
66
+ return outFile;
67
+ }
@@ -0,0 +1,84 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://claude-skills/pptx-diagram/schemas/architecture.schema.json",
4
+ "title": "Native PPTX Architecture Diagram",
5
+ "description": "Components are freely positioned in inches on a 13.33x7.5in (16:9) slide. Boundaries wrap a set of component ids with a computed, padded dashed frame instead of hand-picked coordinates.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema_version", "diagram_type", "meta", "components", "connections"],
9
+ "properties": {
10
+ "schema_version": { "const": 1 },
11
+ "diagram_type": { "const": "architecture" },
12
+ "meta": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "required": ["title"],
16
+ "properties": {
17
+ "title": { "type": "string", "minLength": 1 },
18
+ "subtitle": { "type": "string" },
19
+ "output": { "type": "string" }
20
+ }
21
+ },
22
+ "components": {
23
+ "type": "array",
24
+ "minItems": 1,
25
+ "items": {
26
+ "type": "object",
27
+ "additionalProperties": false,
28
+ "required": ["id", "type", "label", "pos"],
29
+ "properties": {
30
+ "id": { "$ref": "common.schema.json#/$defs/id" },
31
+ "type": { "$ref": "common.schema.json#/$defs/nodeType" },
32
+ "label": { "type": "string", "minLength": 1 },
33
+ "sublabel": { "type": "string" },
34
+ "tag": { "type": "string" },
35
+ "pos": { "$ref": "common.schema.json#/$defs/point" },
36
+ "size": { "$ref": "common.schema.json#/$defs/point", "description": "[w,h] in inches; default [1.8, 0.95]" }
37
+ }
38
+ }
39
+ },
40
+ "boundaries": {
41
+ "type": "array",
42
+ "items": {
43
+ "type": "object",
44
+ "additionalProperties": false,
45
+ "required": ["label", "wraps"],
46
+ "properties": {
47
+ "label": { "type": "string", "minLength": 1 },
48
+ "wraps": { "type": "array", "minItems": 1, "items": { "$ref": "common.schema.json#/$defs/id" } },
49
+ "color": { "$ref": "common.schema.json#/$defs/hexColor" }
50
+ }
51
+ }
52
+ },
53
+ "connections": {
54
+ "type": "array",
55
+ "items": {
56
+ "type": "object",
57
+ "additionalProperties": false,
58
+ "required": ["from", "to", "route"],
59
+ "properties": {
60
+ "from": { "$ref": "common.schema.json#/$defs/id" },
61
+ "to": { "$ref": "common.schema.json#/$defs/id" },
62
+ "label": { "type": "string" },
63
+ "variant": { "enum": ["default", "emphasis", "security", "dashed"] },
64
+ "route": { "$ref": "common.schema.json#/$defs/route" },
65
+ "bias": { "type": "number", "minimum": 0.1, "maximum": 0.9 },
66
+ "clearY": { "type": "number", "minimum": 0.3, "maximum": 7.2, "description": "required when route is 'arc-over-top'" }
67
+ }
68
+ }
69
+ },
70
+ "cards": {
71
+ "type": "array",
72
+ "items": {
73
+ "type": "object",
74
+ "additionalProperties": false,
75
+ "required": ["title", "items"],
76
+ "properties": {
77
+ "title": { "type": "string", "minLength": 1 },
78
+ "color": { "$ref": "common.schema.json#/$defs/hexColor" },
79
+ "items": { "type": "array", "items": { "type": "string" } }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://claude-skills/pptx-diagram/schemas/common.schema.json",
4
+ "title": "Shared definitions",
5
+ "$defs": {
6
+ "id": {
7
+ "type": "string",
8
+ "pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$"
9
+ },
10
+ "point": {
11
+ "type": "array",
12
+ "prefixItems": [{ "type": "number" }, { "type": "number" }],
13
+ "items": false,
14
+ "minItems": 2,
15
+ "maxItems": 2
16
+ },
17
+ "nodeType": {
18
+ "enum": ["client", "frontend", "neutral", "backend", "database", "cache", "queue", "external", "security", "decision"]
19
+ },
20
+ "route": {
21
+ "enum": ["straight", "drop", "elbow-right", "arc-over-top"]
22
+ },
23
+ "hexColor": {
24
+ "type": "string",
25
+ "pattern": "^[0-9A-Fa-f]{6}$"
26
+ }
27
+ }
28
+ }