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/LICENSE +21 -0
- package/README.md +45 -0
- package/SKILL.md +175 -0
- package/bin/cli.mjs +159 -0
- package/examples/onboarding.workflow.json +29 -0
- package/examples/order-processing.architecture.json +29 -0
- package/index.mjs +4 -0
- package/lib/layout.mjs +73 -0
- package/lib/repair-pptx.mjs +73 -0
- package/lib/routes.mjs +112 -0
- package/lib/shapes.mjs +107 -0
- package/lib/validate.mjs +143 -0
- package/package.json +48 -0
- package/renderers/architecture/render-architecture.mjs +68 -0
- package/renderers/workflow/render-workflow.mjs +67 -0
- package/schemas/architecture.schema.json +84 -0
- package/schemas/common.schema.json +28 -0
- package/schemas/workflow.schema.json +86 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hiio420official
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# ptxiagram
|
|
2
|
+
|
|
3
|
+
**Generate architecture and workflow diagrams as native, editable PowerPoint shapes — not an embedded image.**
|
|
4
|
+
|
|
5
|
+
ptxiagram is an agent skill for Claude (and a standalone CLI/library) that turns a plain-English description of a system or process into a `.pptx` slide built from real PowerPoint shapes: rectangles, cylinders, clouds, connectors, text boxes. Anyone can open the result in PowerPoint — or Hancom 한쇼 — and click a box to change its color, text, or position, because it *is* a box, not a picture of one.
|
|
6
|
+
|
|
7
|
+
- **Native shapes, not a picture** — every box and arrow is a real PPTX shape, editable after generation
|
|
8
|
+
- **Two diagram types today** — `workflow` (swimlanes, happy-path flows) and `architecture` (free-placed components, trust boundaries, hub-and-spoke)
|
|
9
|
+
- **A validator that catches real layout bugs before rendering** — node overlap, labels wider than their shape, and connectors that cut through an unrelated node all fail loudly with a specific fix, instead of shipping a broken-looking slide
|
|
10
|
+
- **Ships a Hancom Office compatibility fix** — every generated file is post-processed to strip a documented PptxGenJS defect that PowerPoint silently repairs but Hancom's 한쇼 flags as a corrupted document (see below)
|
|
11
|
+
- **Route presets instead of hand-computed coordinates** — `straight`, `drop`, `elbow-right`, `arc-over-top` cover swimlane transitions, hub-and-spoke fan-out, and routing around a boundary box
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
As a Claude Skill:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx skills add hiio420official/ptxiagram -g
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
As a CLI / library:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g ptxiagram
|
|
25
|
+
ptxiagram doctor
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
ptxiagram render workflow examples/onboarding.workflow.json out.pptx
|
|
32
|
+
ptxiagram check out.pptx
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`render` validates the input (schema + layout) before writing anything, and always repairs the output for Hancom Office compatibility. `check` confirms the repair took, dumps every embedded text run for a spelling/content spot-check, and — if LibreOffice is installed — writes a PNG preview for a quick visual check.
|
|
36
|
+
|
|
37
|
+
See [`ptxiagram/SKILL.md`](ptxiagram/SKILL.md) for the full JSON shape of both diagram types, the node-type → shape/color mapping, and the route presets.
|
|
38
|
+
|
|
39
|
+
## Why every file gets repaired
|
|
40
|
+
|
|
41
|
+
PptxGenJS ships a notes-slide scaffold (6 placeholder shapes) on every file it generates, whether or not notes are used. PowerPoint silently repairs this on open. Hancom Office's 한쇼 does not — it shows a "damaged document" dialog instead, on every file, every time. `lib/repair-pptx.mjs` strips the notes infrastructure and the always-empty `charts`/`embeddings` scaffolding before the file is written. Source: [gitbrent/PptxGenJS#1449](https://github.com/gitbrent/PptxGenJS/issues/1449).
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
MIT
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ptxiagram
|
|
3
|
+
description: Create architecture and workflow diagrams as native, editable PowerPoint shapes (real rectangles, connectors, and text boxes a user can click and restyle in PowerPoint or Hancom 한쇼) instead of an embedded image. Accepts a plain-language description, turns it into a small JSON file, and renders it with pptxgenjs. Use when the user asks for a diagram, architecture drawing, or process flow that needs to end up inside a .pptx deck or a Korean-office-suite-compatible presentation - not when they just want a standalone image or an HTML/web diagram (use an SVG-based diagram tool for that instead).
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
version: "0.1.0"
|
|
7
|
+
status: prototype
|
|
8
|
+
based_on: "gitbrent/PptxGenJS (MIT)"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# ptxiagram
|
|
12
|
+
|
|
13
|
+
Turns a plain-language description of a system or process into a single
|
|
14
|
+
`.pptx` slide built from **native PowerPoint shapes** — real rectangles,
|
|
15
|
+
cylinders, connectors, and text boxes — not a picture pasted into a slide.
|
|
16
|
+
Anyone can then click a box in PowerPoint (or Hancom 한쇼) and change its
|
|
17
|
+
color, text, or position directly.
|
|
18
|
+
|
|
19
|
+
This is the thing to reach for when the deliverable is a slide deck. If the
|
|
20
|
+
deliverable is a standalone image, an HTML page, or a README diagram, an
|
|
21
|
+
SVG/HTML-based diagram tool is the better fit — this skill's whole reason to
|
|
22
|
+
exist is that the output has to remain editable inside PowerPoint.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx skills add hiio420official/ptxiagram -g
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
or as an npm package (CLI + library):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install -g ptxiagram
|
|
34
|
+
ptxiagram doctor
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Setup
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
node bin/cli.mjs doctor
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Confirms pptxgenjs, jszip, and ajv are present, and reports whether
|
|
44
|
+
LibreOffice is installed (optional — enables the `check` command's PNG
|
|
45
|
+
preview for visual QA; without it you still get full schema/layout
|
|
46
|
+
validation and the embedded-text dump, just no rendered preview image).
|
|
47
|
+
|
|
48
|
+
## Choosing a diagram type
|
|
49
|
+
|
|
50
|
+
| Type | Use for | Schema |
|
|
51
|
+
|------|---------|--------|
|
|
52
|
+
| `workflow` | Cross-team processes, approval chains, anything with swimlanes and a happy path | `schemas/workflow.schema.json` |
|
|
53
|
+
| `architecture` | System components, services, datastores, trust boundaries, hub-and-spoke integrations | `schemas/architecture.schema.json` |
|
|
54
|
+
|
|
55
|
+
## The loop
|
|
56
|
+
|
|
57
|
+
1. **Read the matching example** before writing JSON by hand — copy its
|
|
58
|
+
shape, don't guess the field names:
|
|
59
|
+
- `examples/onboarding.workflow.json`
|
|
60
|
+
- `examples/order-processing.architecture.json`
|
|
61
|
+
2. Write `<name>.<type>.json`.
|
|
62
|
+
3. Render:
|
|
63
|
+
```bash
|
|
64
|
+
node bin/cli.mjs render workflow <input>.json <output>.pptx
|
|
65
|
+
```
|
|
66
|
+
This validates the JSON (schema + layout) and **always** repairs the
|
|
67
|
+
output for Hancom Office compatibility before writing the final file —
|
|
68
|
+
see "Why every file gets repaired" below. If layout validation fails, the
|
|
69
|
+
error names exactly what's wrong (which nodes overlap, which label is
|
|
70
|
+
wider than its shape, which edge cuts through an unrelated node) — fix
|
|
71
|
+
the JSON and re-run; don't hand-edit the generated pptx.
|
|
72
|
+
4. Spot-check before handing it over:
|
|
73
|
+
```bash
|
|
74
|
+
node bin/cli.mjs check <output>.pptx
|
|
75
|
+
```
|
|
76
|
+
Confirms the Hancom repair actually took (no leftover notes/chart
|
|
77
|
+
scaffolding), dumps every embedded text run so you can read the real
|
|
78
|
+
strings back (LibreOffice's PNG rendering has shown font-substitution
|
|
79
|
+
artifacts that don't reflect the actual file — trust this text dump over
|
|
80
|
+
the preview image for spelling/content, and trust the PNG for layout).
|
|
81
|
+
If LibreOffice is installed, this also writes a PNG preview next to the
|
|
82
|
+
file so you can visually confirm layout before delivering it.
|
|
83
|
+
|
|
84
|
+
## Workflow JSON shape
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"schema_version": 1,
|
|
89
|
+
"diagram_type": "workflow",
|
|
90
|
+
"meta": { "title": "...", "subtitle": "...", "output": "out.pptx" },
|
|
91
|
+
"lanes": [{ "id": "hr", "label": "HR팀" }],
|
|
92
|
+
"nodes": [
|
|
93
|
+
{ "id": "apply", "lane": "hr", "col": 0, "type": "neutral", "label": "...", "sublabel": "..." }
|
|
94
|
+
],
|
|
95
|
+
"edges": [
|
|
96
|
+
{ "from": "apply", "to": "request", "variant": "default", "route": "straight" }
|
|
97
|
+
],
|
|
98
|
+
"cards": []
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Layout budget**: lanes stack vertically and are auto-positioned (default
|
|
103
|
+
height 1.75in). Each lane has 6 column slots (`col` 0–5, each 2.0in wide);
|
|
104
|
+
a node claims exactly one slot, so default-width nodes can never overlap —
|
|
105
|
+
omit `col` and the renderer assigns the next free slot in that lane in JSON
|
|
106
|
+
order. Default node size is 1.8×0.95in.
|
|
107
|
+
|
|
108
|
+
## Architecture JSON shape
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"schema_version": 1,
|
|
113
|
+
"diagram_type": "architecture",
|
|
114
|
+
"meta": { "title": "...", "output": "out.pptx" },
|
|
115
|
+
"components": [
|
|
116
|
+
{ "id": "api", "type": "backend", "label": "API 서버", "pos": [5.9, 3.1], "size": [1.85, 1.1] }
|
|
117
|
+
],
|
|
118
|
+
"boundaries": [{ "label": "사내 인프라 (VPC)", "wraps": ["api", "db"] }],
|
|
119
|
+
"connections": [
|
|
120
|
+
{ "from": "api", "to": "db", "variant": "default", "route": "elbow-right", "label": "주문 저장" }
|
|
121
|
+
],
|
|
122
|
+
"cards": []
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Layout budget**: free placement in inches on a 13.33×7.5in (16:9) slide.
|
|
127
|
+
`boundaries[].wraps` takes component ids, not coordinates — the renderer
|
|
128
|
+
computes a padded frame around them, so never hand-compute a boundary box.
|
|
129
|
+
|
|
130
|
+
## Node types
|
|
131
|
+
|
|
132
|
+
`client` `frontend` `neutral` `backend` `database` `cache` `queue`
|
|
133
|
+
`external` `security` `decision` — each maps to a shape preset (ellipse,
|
|
134
|
+
rect, roundRect, can/cylinder, hexagon, cube, cloud, diamond) and a color
|
|
135
|
+
pair. Pick by role, not by desired color.
|
|
136
|
+
|
|
137
|
+
## Route presets
|
|
138
|
+
|
|
139
|
+
Connectors never need hand-computed coordinates — pick the preset that
|
|
140
|
+
matches the story:
|
|
141
|
+
|
|
142
|
+
- `straight` — adjacent nodes, same row. One segment.
|
|
143
|
+
- `drop` — top-to-bottom flow (cross-lane, or stacked components). Straight
|
|
144
|
+
if the two nodes share an x-center, otherwise a vertical→horizontal→vertical
|
|
145
|
+
jog through the midpoint (`bias` picks where, default 0.5).
|
|
146
|
+
- `elbow-right` — hub-and-spoke fan-out where targets sit at different row
|
|
147
|
+
heights. Straight if the two nodes share a y-center, otherwise a
|
|
148
|
+
horizontal→vertical→horizontal jog.
|
|
149
|
+
- `arc-over-top` — routes above a shared clearance line (`clearY`, required)
|
|
150
|
+
instead of cutting through whatever sits between two nodes. Use this for
|
|
151
|
+
a connection that has to reach past a boundary box or a row of components
|
|
152
|
+
a straight/elbow line would otherwise cross.
|
|
153
|
+
|
|
154
|
+
`node overlaps`, `label wider than its shape`, and `edge crosses an
|
|
155
|
+
unrelated node` are exactly the failure modes `render`/`validate` catch
|
|
156
|
+
before a file is written — if you hit one, change the route or `bias`
|
|
157
|
+
rather than fighting raw coordinates.
|
|
158
|
+
|
|
159
|
+
## Why every file gets repaired
|
|
160
|
+
|
|
161
|
+
pptxgenjs ships a notes-slide scaffold (6 placeholder shapes) on every
|
|
162
|
+
file it generates, regardless of whether notes are used. PowerPoint
|
|
163
|
+
silently repairs this on open; Hancom Office's 한쇼 does not — it shows a
|
|
164
|
+
"damaged document" dialog instead. `lib/repair-pptx.mjs` strips the notes
|
|
165
|
+
infrastructure and the always-empty `charts`/`embeddings` scaffolding
|
|
166
|
+
after generation and before the file is written. Every `render` call does
|
|
167
|
+
this automatically; `check` verifies it actually happened. Source:
|
|
168
|
+
[gitbrent/PptxGenJS#1449](https://github.com/gitbrent/PptxGenJS/issues/1449).
|
|
169
|
+
|
|
170
|
+
## What's not built yet
|
|
171
|
+
|
|
172
|
+
- `sequence`, `dataflow`, `lifecycle` diagram types (only `workflow` and
|
|
173
|
+
`architecture` exist so far)
|
|
174
|
+
- No CJK exact-metrics font measurement — the label-width check is a
|
|
175
|
+
conservative estimate, not real glyph widths
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import JSZip from "jszip";
|
|
8
|
+
import { renderWorkflow } from "../renderers/workflow/render-workflow.mjs";
|
|
9
|
+
import { renderArchitecture } from "../renderers/architecture/render-architecture.mjs";
|
|
10
|
+
import { validateAll } from "../lib/validate.mjs";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
const RENDERERS = { workflow: renderWorkflow, architecture: renderArchitecture };
|
|
15
|
+
|
|
16
|
+
function usage() {
|
|
17
|
+
console.log(`Usage:
|
|
18
|
+
cli render <type> <input.json> [output.pptx]
|
|
19
|
+
cli validate <type> <input.json> [--json]
|
|
20
|
+
cli check <output.pptx>
|
|
21
|
+
cli doctor
|
|
22
|
+
|
|
23
|
+
Types: workflow, architecture`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function cmdRender(type, inputPath, outputPath) {
|
|
27
|
+
const renderFn = RENDERERS[type];
|
|
28
|
+
if (!renderFn) throw new Error(`Unknown type "${type}". Expected one of: ${Object.keys(RENDERERS).join(", ")}`);
|
|
29
|
+
const outFile = await renderFn(inputPath, outputPath);
|
|
30
|
+
console.log(outFile);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function cmdValidate(type, inputPath, asJson) {
|
|
34
|
+
const raw = JSON.parse(await fs.readFile(inputPath, "utf8"));
|
|
35
|
+
try {
|
|
36
|
+
const problems = await validateAll(type, raw);
|
|
37
|
+
const result = { ok: problems.length === 0, type, input: inputPath, problems };
|
|
38
|
+
if (asJson) {
|
|
39
|
+
console.log(JSON.stringify(result, null, 2));
|
|
40
|
+
} else if (problems.length === 0) {
|
|
41
|
+
console.log("OK: schema and layout are clean.");
|
|
42
|
+
} else {
|
|
43
|
+
console.log(`Layout problems (${problems.length}):`);
|
|
44
|
+
for (const p of problems) console.log(`- ${p}`);
|
|
45
|
+
}
|
|
46
|
+
if (problems.length) process.exitCode = 1;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (asJson) {
|
|
49
|
+
console.log(JSON.stringify({ ok: false, type, input: inputPath, error: err.message }, null, 2));
|
|
50
|
+
} else {
|
|
51
|
+
console.error(err.message);
|
|
52
|
+
}
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function findSoffice() {
|
|
58
|
+
const candidates = [
|
|
59
|
+
"C:/Program Files/LibreOffice/program/soffice.exe",
|
|
60
|
+
"/usr/bin/soffice",
|
|
61
|
+
"/opt/libreoffice/program/soffice",
|
|
62
|
+
];
|
|
63
|
+
return candidates.find((p) => existsSync(p));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function cmdCheck(pptxPath) {
|
|
67
|
+
const buf = await fs.readFile(pptxPath);
|
|
68
|
+
const zip = await JSZip.loadAsync(buf);
|
|
69
|
+
const problems = [];
|
|
70
|
+
|
|
71
|
+
const hasNotes = Object.keys(zip.files).some(
|
|
72
|
+
(n) => n.startsWith("ppt/notesMasters/") || n.startsWith("ppt/notesSlides/")
|
|
73
|
+
);
|
|
74
|
+
if (hasNotes) problems.push("notesMaster/notesSlide parts present — repairPptx() was not applied (Hancom Office will flag this as corrupted).");
|
|
75
|
+
|
|
76
|
+
const hasEmptyScaffold = Object.keys(zip.files).some(
|
|
77
|
+
(n) => n.startsWith("ppt/charts/") || n.startsWith("ppt/embeddings/")
|
|
78
|
+
);
|
|
79
|
+
if (hasEmptyScaffold) problems.push("empty ppt/charts/ or ppt/embeddings/ scaffolding present.");
|
|
80
|
+
|
|
81
|
+
const slideFiles = Object.keys(zip.files).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n));
|
|
82
|
+
const texts = [];
|
|
83
|
+
for (const name of slideFiles) {
|
|
84
|
+
const content = await zip.file(name).async("string");
|
|
85
|
+
const matches = [...content.matchAll(/<a:t>([^<]*)<\/a:t>/g)].map((m) => m[1]);
|
|
86
|
+
texts.push(...matches);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log(JSON.stringify({ ok: problems.length === 0, file: pptxPath, problems, embeddedText: texts }, null, 2));
|
|
90
|
+
|
|
91
|
+
const soffice = findSoffice();
|
|
92
|
+
if (soffice) {
|
|
93
|
+
const outDir = path.join(path.dirname(pptxPath), "render");
|
|
94
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
95
|
+
await execFileAsync(soffice, ["--headless", "--convert-to", "png", "--outdir", outDir, pptxPath]);
|
|
96
|
+
const pngName = path.basename(pptxPath).replace(/\.pptx$/, ".png");
|
|
97
|
+
console.log(`PNG preview: ${path.join(outDir, pngName)}`);
|
|
98
|
+
} else {
|
|
99
|
+
console.log("LibreOffice not found — skipping PNG preview. Install it for visual QA (soffice --headless --convert-to png).");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (problems.length) process.exitCode = 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function cmdDoctor() {
|
|
106
|
+
const checks = [];
|
|
107
|
+
try {
|
|
108
|
+
await import("pptxgenjs");
|
|
109
|
+
checks.push(["pptxgenjs", true]);
|
|
110
|
+
} catch {
|
|
111
|
+
checks.push(["pptxgenjs", false]);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
await import("jszip");
|
|
115
|
+
checks.push(["jszip", true]);
|
|
116
|
+
} catch {
|
|
117
|
+
checks.push(["jszip", false]);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
await import("ajv/dist/2020.js");
|
|
121
|
+
checks.push(["ajv", true]);
|
|
122
|
+
} catch {
|
|
123
|
+
checks.push(["ajv", false]);
|
|
124
|
+
}
|
|
125
|
+
const soffice = findSoffice();
|
|
126
|
+
checks.push(["LibreOffice (optional, enables `check` PNG preview + visual QA)", Boolean(soffice)]);
|
|
127
|
+
|
|
128
|
+
for (const [name, ok] of checks) {
|
|
129
|
+
console.log(`[${ok ? "ok" : "MISSING"}] ${name}`);
|
|
130
|
+
}
|
|
131
|
+
if (checks.some(([, ok]) => !ok)) process.exitCode = 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const [, , cmd, ...rest] = process.argv;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
if (cmd === "render") {
|
|
138
|
+
const [type, input, output] = rest;
|
|
139
|
+
if (!type || !input) { usage(); process.exit(1); }
|
|
140
|
+
await cmdRender(type, input, output);
|
|
141
|
+
} else if (cmd === "validate") {
|
|
142
|
+
const asJson = rest.includes("--json");
|
|
143
|
+
const [type, input] = rest.filter((a) => a !== "--json");
|
|
144
|
+
if (!type || !input) { usage(); process.exit(1); }
|
|
145
|
+
await cmdValidate(type, input, asJson);
|
|
146
|
+
} else if (cmd === "check") {
|
|
147
|
+
const [pptxPath] = rest;
|
|
148
|
+
if (!pptxPath) { usage(); process.exit(1); }
|
|
149
|
+
await cmdCheck(pptxPath);
|
|
150
|
+
} else if (cmd === "doctor") {
|
|
151
|
+
await cmdDoctor();
|
|
152
|
+
} else {
|
|
153
|
+
usage();
|
|
154
|
+
process.exit(cmd ? 1 : 0);
|
|
155
|
+
}
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.error(err.message);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"diagram_type": "workflow",
|
|
4
|
+
"meta": {
|
|
5
|
+
"title": "신규 직원 온보딩 프로세스",
|
|
6
|
+
"subtitle": "HR팀 → IT팀 → 팀장",
|
|
7
|
+
"output": "test/output/onboarding.schema-driven.pptx"
|
|
8
|
+
},
|
|
9
|
+
"lanes": [
|
|
10
|
+
{ "id": "hr", "label": "HR팀" },
|
|
11
|
+
{ "id": "it", "label": "IT팀" },
|
|
12
|
+
{ "id": "lead", "label": "팀장" }
|
|
13
|
+
],
|
|
14
|
+
"nodes": [
|
|
15
|
+
{ "id": "apply", "lane": "hr", "col": 0, "type": "neutral", "label": "입사 서류 접수", "sublabel": "인사팀 접수 확인" },
|
|
16
|
+
{ "id": "request", "lane": "hr", "col": 1, "type": "neutral", "label": "계정 발급 요청", "sublabel": "IT팀에 요청 전달" },
|
|
17
|
+
{ "id": "account", "lane": "it", "col": 1, "type": "backend", "label": "계정 생성", "sublabel": "사내 계정 · 이메일" },
|
|
18
|
+
{ "id": "equipment", "lane": "it", "col": 2, "type": "backend", "label": "장비 지급", "sublabel": "노트북 · 출입카드" },
|
|
19
|
+
{ "id": "kickoff", "lane": "lead", "col": 3, "type": "database", "label": "킥오프 미팅", "sublabel": "팀 소개 · 업무 안내" },
|
|
20
|
+
{ "id": "done", "lane": "lead", "col": 4, "type": "database", "label": "온보딩 완료", "sublabel": "1주차 체크인 예약" }
|
|
21
|
+
],
|
|
22
|
+
"edges": [
|
|
23
|
+
{ "from": "apply", "to": "request", "variant": "default", "route": "straight" },
|
|
24
|
+
{ "from": "request", "to": "account", "variant": "emphasis", "route": "drop", "label": "요청 전달" },
|
|
25
|
+
{ "from": "account", "to": "equipment", "variant": "emphasis", "route": "straight" },
|
|
26
|
+
{ "from": "equipment", "to": "kickoff", "variant": "default", "route": "drop", "label": "장비 수령 후" },
|
|
27
|
+
{ "from": "kickoff", "to": "done", "variant": "default", "route": "straight" }
|
|
28
|
+
]
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"diagram_type": "architecture",
|
|
4
|
+
"meta": {
|
|
5
|
+
"title": "온라인 쇼핑몰 주문처리 아키텍처",
|
|
6
|
+
"subtitle": "클라이언트 → API 서버 → 내부 인프라 / 외부 결제 PG",
|
|
7
|
+
"output": "test/output/order-processing.schema-driven.pptx"
|
|
8
|
+
},
|
|
9
|
+
"components": [
|
|
10
|
+
{ "id": "client", "type": "client", "label": "사용자", "sublabel": "브라우저", "pos": [0.4, 3.1], "size": [1.5, 1.1] },
|
|
11
|
+
{ "id": "web", "type": "frontend", "label": "웹 / 앱", "sublabel": "React", "pos": [2.35, 3.1], "size": [1.7, 1.1] },
|
|
12
|
+
{ "id": "api", "type": "backend", "label": "API 서버", "sublabel": "주문 처리", "pos": [5.9, 3.1], "size": [1.85, 1.1] },
|
|
13
|
+
{ "id": "db", "type": "database", "label": "주문 DB", "pos": [8.4, 1.55], "size": [1.0, 1.2] },
|
|
14
|
+
{ "id": "cache", "type": "cache", "label": "캐시", "sublabel": "Redis", "pos": [8.3, 3.1], "size": [1.15, 1.1] },
|
|
15
|
+
{ "id": "queue", "type": "queue", "label": "알림 큐", "pos": [8.3, 4.55], "size": [1.15, 1.05] },
|
|
16
|
+
{ "id": "pg", "type": "external", "label": "결제 PG사", "sublabel": "외부 서비스", "pos": [10.4, 3.0], "size": [2.1, 1.3] }
|
|
17
|
+
],
|
|
18
|
+
"boundaries": [
|
|
19
|
+
{ "label": "사내 인프라 (VPC)", "wraps": ["api", "db", "cache", "queue"], "color": "F59E0B" }
|
|
20
|
+
],
|
|
21
|
+
"connections": [
|
|
22
|
+
{ "from": "client", "to": "web", "variant": "default", "route": "straight" },
|
|
23
|
+
{ "from": "web", "to": "api", "variant": "emphasis", "route": "straight" },
|
|
24
|
+
{ "from": "api", "to": "db", "variant": "default", "route": "elbow-right", "label": "주문 저장" },
|
|
25
|
+
{ "from": "api", "to": "cache", "variant": "default", "route": "elbow-right" },
|
|
26
|
+
{ "from": "api", "to": "queue", "variant": "default", "route": "elbow-right", "label": "발송 알림" },
|
|
27
|
+
{ "from": "api", "to": "pg", "variant": "dashed", "route": "arc-over-top", "clearY": 1.0, "label": "결제 요청 (async)" }
|
|
28
|
+
]
|
|
29
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { renderWorkflow } from "./renderers/workflow/render-workflow.mjs";
|
|
2
|
+
export { renderArchitecture } from "./renderers/architecture/render-architecture.mjs";
|
|
3
|
+
export { validateAll, validateSchema, validateWorkflowLayout, validateArchitectureLayout } from "./lib/validate.mjs";
|
|
4
|
+
export { repairPptx } from "./lib/repair-pptx.mjs";
|
package/lib/layout.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Pure layout math shared by the renderers (which draw the boxes this
|
|
2
|
+
// computes) and the validator (which checks them before anything is drawn).
|
|
3
|
+
// Keeping this in one place means the renderer and the validator can never
|
|
4
|
+
// silently drift apart.
|
|
5
|
+
|
|
6
|
+
export const SLIDE_W = 13.33, SLIDE_H = 7.5;
|
|
7
|
+
|
|
8
|
+
export const WORKFLOW_LAYOUT = {
|
|
9
|
+
LANE_X: 0.4, LANE_W: 12.5,
|
|
10
|
+
LANE_START_Y: 1.15, LANE_GAP: 0.15, DEFAULT_LANE_H: 1.75,
|
|
11
|
+
COL_X0: 0.6, COL_WIDTH: 2.0,
|
|
12
|
+
NODE_W: 1.8, NODE_H: 0.95, NODE_Y_OFFSET: 0.35,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function computeWorkflowLayout(raw) {
|
|
16
|
+
const L = WORKFLOW_LAYOUT;
|
|
17
|
+
let y = L.LANE_START_Y;
|
|
18
|
+
const laneBoxes = {};
|
|
19
|
+
for (const lane of raw.lanes) {
|
|
20
|
+
const h = lane.height || L.DEFAULT_LANE_H;
|
|
21
|
+
laneBoxes[lane.id] = { x: L.LANE_X, y, w: L.LANE_W, h };
|
|
22
|
+
y += h + L.LANE_GAP;
|
|
23
|
+
}
|
|
24
|
+
const cardsY = y + 0.1;
|
|
25
|
+
|
|
26
|
+
const usedCols = {};
|
|
27
|
+
for (const n of raw.nodes) {
|
|
28
|
+
if (n.col !== undefined) (usedCols[n.lane] ||= new Set()).add(n.col);
|
|
29
|
+
}
|
|
30
|
+
const nodeBoxes = {};
|
|
31
|
+
const nodeMeta = {};
|
|
32
|
+
for (const n of raw.nodes) {
|
|
33
|
+
const laneBox = laneBoxes[n.lane];
|
|
34
|
+
if (!laneBox) throw new Error(`Node "${n.id}" references unknown lane "${n.lane}"`);
|
|
35
|
+
let col = n.col;
|
|
36
|
+
if (col === undefined) {
|
|
37
|
+
const used = (usedCols[n.lane] ||= new Set());
|
|
38
|
+
col = 0;
|
|
39
|
+
while (used.has(col)) col++;
|
|
40
|
+
used.add(col);
|
|
41
|
+
}
|
|
42
|
+
const w = n.width || L.NODE_W;
|
|
43
|
+
const h = n.height || L.NODE_H;
|
|
44
|
+
nodeBoxes[n.id] = { x: L.COL_X0 + col * L.COL_WIDTH, y: laneBox.y + L.NODE_Y_OFFSET, w, h };
|
|
45
|
+
nodeMeta[n.id] = { lane: n.lane, col };
|
|
46
|
+
}
|
|
47
|
+
return { laneBoxes, nodeBoxes, nodeMeta, cardsY };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const BOUNDARY_PAD = 0.3;
|
|
51
|
+
|
|
52
|
+
export function computeArchitectureLayout(raw) {
|
|
53
|
+
const boxes = {};
|
|
54
|
+
for (const c of raw.components) {
|
|
55
|
+
const [x, y] = c.pos;
|
|
56
|
+
const [w, h] = c.size || [1.8, 0.95];
|
|
57
|
+
boxes[c.id] = { x, y, w, h };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const boundaryBoxes = (raw.boundaries || []).map((b) => {
|
|
61
|
+
const wrapped = b.wraps.map((id) => {
|
|
62
|
+
if (!boxes[id]) throw new Error(`Boundary "${b.label}" wraps unknown component "${id}"`);
|
|
63
|
+
return boxes[id];
|
|
64
|
+
});
|
|
65
|
+
const minX = Math.min(...wrapped.map((n) => n.x)) - BOUNDARY_PAD;
|
|
66
|
+
const minY = Math.min(...wrapped.map((n) => n.y)) - BOUNDARY_PAD;
|
|
67
|
+
const maxX = Math.max(...wrapped.map((n) => n.x + n.w)) + BOUNDARY_PAD;
|
|
68
|
+
const maxY = Math.max(...wrapped.map((n) => n.y + n.h)) + BOUNDARY_PAD;
|
|
69
|
+
return { label: b.label, color: b.color, box: { x: minX, y: minY, w: maxX - minX, h: maxY - minY } };
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return { boxes, boundaryBoxes };
|
|
73
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import JSZip from "jszip";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fixes known PptxGenJS OOXML defects that PowerPoint and Hancom Office's
|
|
6
|
+
* 한쇼 flag as a corrupted/damaged document (LibreOffice is lenient and
|
|
7
|
+
* renders the unfixed output fine, which is why the defects go unnoticed
|
|
8
|
+
* until a stricter parser opens the file).
|
|
9
|
+
*
|
|
10
|
+
* Source: https://github.com/gitbrent/PptxGenJS/issues/1449
|
|
11
|
+
* - Defect 5: notesMaster1.xml always ships 6 placeholder shapes
|
|
12
|
+
* (hdr/dt/sldImg/body/ftr/sldNum) that PowerPoint-family parsers
|
|
13
|
+
* consider malformed. PptxGenJS emits this notes scaffold on every
|
|
14
|
+
* file regardless of whether notes are used, so every generated
|
|
15
|
+
* pptx carries it.
|
|
16
|
+
* - Defect 6: empty ppt/charts/ and ppt/embeddings/ directories are
|
|
17
|
+
* always created even when no chart/embedding exists.
|
|
18
|
+
*
|
|
19
|
+
* Strategy: since our diagrams never use PPT speaker notes or embedded
|
|
20
|
+
* charts, the simplest fix is to strip the notes infrastructure and the
|
|
21
|
+
* empty scaffold directories entirely rather than trying to make them
|
|
22
|
+
* spec-conformant.
|
|
23
|
+
*/
|
|
24
|
+
export async function repairPptx(filePath) {
|
|
25
|
+
const buf = await fs.readFile(filePath);
|
|
26
|
+
const zip = await JSZip.loadAsync(buf);
|
|
27
|
+
|
|
28
|
+
// Defect 5: drop notes parts
|
|
29
|
+
for (const name of Object.keys(zip.files)) {
|
|
30
|
+
if (name.startsWith("ppt/notesMasters/") || name.startsWith("ppt/notesSlides/")) {
|
|
31
|
+
zip.remove(name);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Defect 6: drop empty chart/embedding scaffolding
|
|
36
|
+
for (const name of Object.keys(zip.files)) {
|
|
37
|
+
if (name.startsWith("ppt/charts/") || name.startsWith("ppt/embeddings/")) {
|
|
38
|
+
zip.remove(name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// [Content_Types].xml: remove notesMaster/notesSlide Overrides
|
|
43
|
+
const ctPath = "[Content_Types].xml";
|
|
44
|
+
let ct = await zip.file(ctPath).async("string");
|
|
45
|
+
ct = ct.replace(/<Override[^>]*PartName="\/ppt\/notesMasters\/[^"]*"[^>]*\/>/g, "");
|
|
46
|
+
ct = ct.replace(/<Override[^>]*PartName="\/ppt\/notesSlides\/[^"]*"[^>]*\/>/g, "");
|
|
47
|
+
zip.file(ctPath, ct);
|
|
48
|
+
|
|
49
|
+
// presentation.xml: remove <p:notesMasterIdLst>
|
|
50
|
+
const presPath = "ppt/presentation.xml";
|
|
51
|
+
let pres = await zip.file(presPath).async("string");
|
|
52
|
+
pres = pres.replace(/<p:notesMasterIdLst>[\s\S]*?<\/p:notesMasterIdLst>/g, "");
|
|
53
|
+
zip.file(presPath, pres);
|
|
54
|
+
|
|
55
|
+
// presentation.xml.rels: remove notesMaster relationship
|
|
56
|
+
const presRelsPath = "ppt/_rels/presentation.xml.rels";
|
|
57
|
+
let presRels = await zip.file(presRelsPath).async("string");
|
|
58
|
+
presRels = presRels.replace(/<Relationship[^>]*Type="[^"]*\/notesMaster"[^>]*\/>/g, "");
|
|
59
|
+
zip.file(presRelsPath, presRels);
|
|
60
|
+
|
|
61
|
+
// each slide's .rels: remove notesSlide relationship
|
|
62
|
+
const slideRelsPattern = /^ppt\/slides\/_rels\/slide\d+\.xml\.rels$/;
|
|
63
|
+
for (const name of Object.keys(zip.files)) {
|
|
64
|
+
if (slideRelsPattern.test(name)) {
|
|
65
|
+
let content = await zip.file(name).async("string");
|
|
66
|
+
const cleaned = content.replace(/<Relationship[^>]*Type="[^"]*\/notesSlide"[^>]*\/>/g, "");
|
|
67
|
+
if (cleaned !== content) zip.file(name, cleaned);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const out = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
|
|
72
|
+
await fs.writeFile(filePath, out);
|
|
73
|
+
}
|