@1agh/maude 0.23.0 → 0.25.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.
Files changed (127) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dependencies.json +30 -2
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
  27. package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
  28. package/plugins/design/dev-server/api.ts +123 -4
  29. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  30. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  31. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  32. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  33. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  34. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  35. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  36. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  37. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  38. package/plugins/design/dev-server/bin/prep.sh +211 -0
  39. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  40. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  41. package/plugins/design/dev-server/bin/smoke.sh +208 -23
  42. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  43. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  44. package/plugins/design/dev-server/canvas-cursors.ts +173 -0
  45. package/plugins/design/dev-server/canvas-icons.tsx +299 -0
  46. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  47. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  48. package/plugins/design/dev-server/canvas-shell.tsx +404 -66
  49. package/plugins/design/dev-server/client/app.jsx +1231 -416
  50. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  51. package/plugins/design/dev-server/collab/index.ts +87 -9
  52. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  53. package/plugins/design/dev-server/collab/registry.ts +80 -2
  54. package/plugins/design/dev-server/collab/room.ts +21 -8
  55. package/plugins/design/dev-server/context-menu.tsx +167 -23
  56. package/plugins/design/dev-server/context.ts +24 -0
  57. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  58. package/plugins/design/dev-server/dist/client.bundle.js +364 -29
  59. package/plugins/design/dev-server/dist/comment-mount.js +78 -33
  60. package/plugins/design/dev-server/dist/styles.css +16 -0
  61. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  62. package/plugins/design/dev-server/export-dialog.tsx +208 -18
  63. package/plugins/design/dev-server/exporters/html.ts +4 -1
  64. package/plugins/design/dev-server/exporters/index.ts +4 -1
  65. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  66. package/plugins/design/dev-server/exporters/png.ts +21 -2
  67. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  68. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  69. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  70. package/plugins/design/dev-server/fs-watch.ts +1 -0
  71. package/plugins/design/dev-server/http.ts +299 -28
  72. package/plugins/design/dev-server/input-router.tsx +35 -5
  73. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  74. package/plugins/design/dev-server/server.ts +123 -1
  75. package/plugins/design/dev-server/sync/agent.ts +95 -0
  76. package/plugins/design/dev-server/sync/codec.ts +155 -0
  77. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  78. package/plugins/design/dev-server/sync/index.ts +479 -35
  79. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  80. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  81. package/plugins/design/dev-server/sync/origins.ts +57 -0
  82. package/plugins/design/dev-server/sync/projection.ts +368 -0
  83. package/plugins/design/dev-server/sync/status.ts +115 -0
  84. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  85. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  86. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  87. package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
  88. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
  89. package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
  90. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  91. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  92. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  93. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  94. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  95. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  96. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  97. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  98. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  99. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  100. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  101. package/plugins/design/dev-server/test/input-router.test.ts +32 -4
  102. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
  103. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  104. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  105. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  106. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  107. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  108. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  109. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  110. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  111. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  112. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  113. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
  114. package/plugins/design/dev-server/tool-palette.tsx +155 -24
  115. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  116. package/plugins/design/dev-server/undo-stack.ts +20 -4
  117. package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
  118. package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
  119. package/plugins/design/dev-server/ws.ts +40 -1
  120. package/plugins/design/templates/_shell.html +85 -10
  121. package/plugins/design/templates/canvas.tsx.template +13 -0
  122. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  123. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  124. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  125. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  127. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -1,19 +1,32 @@
1
- // Phase 6.5 T6b PPTX adapter via `dom-to-pptx`.
1
+ // PPTX adapternative editable slides via svg2pptx (export-pipeline-fixes
2
+ // item 6, final architecture; see DDR-069).
2
3
  //
3
- // The hand-rolled walker + classifier from the previous iteration produced
4
- // PPTX where text/shape coordinates were collapsed near origin and colours
5
- // were lost — see DDR-043 for the swap rationale. `dom-to-pptx` runs inside
6
- // the page, reads computed styles + getBoundingClientRect for each element,
7
- // and emits one shape per node the layout the browser already computed.
8
- // Multi-artboard exports concatenate at the byte level via a re-walk per
9
- // artboard.
4
+ // Pipeline per artboard:
5
+ // 1. Render the artboard to SVG (the project's vector pipeline: exact glyph
6
+ // positions, oklch→sRGB, fonts inlined).
7
+ // 2. Pre-process the SVG (`preprocessSvg`): lift each `<tspan>` x/y onto its
8
+ // `<text>` parent (dom-to-svg positions on the tspan; svg2pptx reads the
9
+ // text element without this every run collapses to 0,0 and overlaps),
10
+ // and collapse the CSS `font-family` stack to its first concrete name (a
11
+ // PPTX `typeface` is a SINGLE font, not a fallback list).
12
+ // 3. svg2pptx converts the SVG to NATIVE PowerPoint shapes + text boxes —
13
+ // editable in PowerPoint / Keynote / Canva, opens everywhere (no `svgBlip`
14
+ // that Canva rejects, no text reflow that broke dom-to-pptx).
15
+ // 4. Merge the per-artboard single-slide decks into one (`mergeDecks`).
16
+ //
17
+ // svg2pptx is a Python tool (the only OSS path that yields faithful + editable
18
+ // native objects — see DDR-069 for the alternatives ruled out: dom-to-pptx
19
+ // reflows, SVG-image `svgBlip` breaks Canva, LibreOffice emits empty). When
20
+ // python3 + svg2pptx are absent we fall back to a PNG-per-slide deck (faithful,
21
+ // universal, Canva-safe, NOT editable) and log a one-line install hint.
10
22
 
11
- import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
23
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
24
  import { tmpdir } from 'node:os';
13
25
  import path from 'node:path';
14
26
 
15
- import path_dirname from 'node:path';
27
+ import PptxGenJS from 'pptxgenjs';
16
28
 
29
+ import { getBrowserBundle } from './_browser-bundles.ts';
17
30
  import {
18
31
  type ExportContext,
19
32
  type ExportOptions,
@@ -22,44 +35,24 @@ import {
22
35
  } from './index.ts';
23
36
  import type { Target } from './scope.ts';
24
37
 
25
- const PPTX_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_pptx-playwright.mjs');
26
- const ENUMERATE_PLAYWRIGHT = path.join(
27
- import.meta.dir,
28
- '..',
29
- 'bin',
30
- '_enumerate-artboards-playwright.mjs'
31
- );
32
- // dom-to-pptx ships a pre-bundled UMD that exposes `window.domToPptx`. The
33
- // exports map hides the bundle path (only `.` is listed), so we resolve via
34
- // the package.json directory instead of `require.resolve('dom-to-pptx/dist/…')`.
35
- function pptxBundlePath(): string {
36
- const pkgJson = require.resolve('dom-to-pptx/package.json');
37
- return path.join(path.dirname(pkgJson), 'dist', 'dom-to-pptx.bundle.js');
38
+ const SVG_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_svg-playwright.mjs');
39
+ const PNG_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_png-playwright.mjs');
40
+ const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
41
+ const SLIDE_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide';
42
+ const SLIDE_CT = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml';
43
+ const SCREEN_DPI = 96; // canvas px → inches (PNG fallback slide sizing)
44
+ const FALLBACK_SCALE = 3; // raster fallback rendered at 3× for crisp non-vector viewers
45
+
46
+ type ElementTarget = Extract<Target, { kind: 'element' }>;
47
+
48
+ function isElementTarget(t: Target): t is ElementTarget {
49
+ return t.kind === 'element';
38
50
  }
39
51
 
40
- async function captureOne(
41
- target: Extract<Target, { kind: 'element' }>,
42
- ctx: ExportContext,
43
- outFile: string,
44
- timeoutSec: number,
45
- bundlePath: string,
46
- selector?: string
47
- ): Promise<void> {
48
- const args = [
49
- PPTX_PLAYWRIGHT,
50
- '--url',
51
- canvasShellUrl(ctx, target.file),
52
- '--selector',
53
- selector ?? target.cssPath,
54
- '--out',
55
- outFile,
56
- '--bundle-path',
57
- bundlePath,
58
- '--timeout',
59
- String(timeoutSec),
60
- ];
52
+ /** Spawn a playwright shim via `node`, returning the written file paths (stdout). */
53
+ async function spawnShim(args: string[]): Promise<string[]> {
61
54
  const proc = Bun.spawn(['node', ...args], {
62
- cwd: path.dirname(PPTX_PLAYWRIGHT),
55
+ cwd: path.dirname(args[0]),
63
56
  stdout: 'pipe',
64
57
  stderr: 'pipe',
65
58
  });
@@ -69,195 +62,331 @@ async function captureOne(
69
62
  ]);
70
63
  const code = await proc.exited;
71
64
  if (code !== 0) {
72
- throw new Error(`_pptx-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
65
+ throw new Error(`${path.basename(args[0])} exited ${code}: ${stderr.trim() || stdout.trim()}`);
73
66
  }
67
+ return stdout
68
+ .split('\n')
69
+ .map((s) => s.trim())
70
+ .filter(Boolean);
74
71
  }
75
72
 
76
- export async function run(
77
- targets: Target[],
78
- options: ExportOptions,
79
- ctx: ExportContext
80
- ): Promise<ExportResult> {
81
- if (!targets.length) {
82
- return {
83
- filename: 'export.pptx',
84
- contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
85
- body: new Uint8Array(0),
86
- };
87
- }
88
- const elementTargets = targets.filter(
89
- (t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element'
90
- );
91
- if (!elementTargets.length) {
92
- throw new Error('pptx adapter requires element targets (got file-tree)');
93
- }
94
- const timeoutSec = (options.timeoutSec as number | undefined) ?? 20;
95
- const bundlePath = pptxBundlePath();
96
- const tmp = mkdtempSync(path.join(tmpdir(), 'maude-pptx-'));
97
-
98
- try {
99
- // Resolve the artboard set we need to render. For `multi: true` we walk
100
- // `[data-dc-screen]` and render each separately, then merge.
101
- const target = elementTargets[0];
102
- if (!target) throw new Error('pptx adapter: no element target');
103
- const baseSlug = target.canvasSlug ?? 'export';
73
+ // ─── SVG pre-processing (the fix that makes svg2pptx faithful) ────────────────
104
74
 
105
- // For canvas-as-separate (`multi: true`), render each artboard as its
106
- // own PPTX, then concatenate the slides into a single deck.
107
- if (target.multi) {
108
- const artboardIds = await enumerateArtboards(target, ctx, timeoutSec);
109
- const perArtboardFiles: string[] = [];
110
- for (let i = 0; i < artboardIds.length; i += 1) {
111
- const id = artboardIds[i];
112
- const outFile = path.join(tmp, `artboard-${i + 1}.pptx`);
113
- await captureOne(target, ctx, outFile, timeoutSec, bundlePath, `[data-dc-screen="${id}"]`);
114
- perArtboardFiles.push(outFile);
115
- }
116
- const merged = await mergePptx(perArtboardFiles);
117
- return {
118
- filename: `${baseSlug}.pptx`,
119
- contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
120
- body: merged,
121
- };
75
+ /**
76
+ * Make a dom-to-svg SVG consumable by svg2pptx without text collapsing to the
77
+ * origin or rendering in a fallback font. Two transforms:
78
+ * - lift the first `<tspan>` x/y onto the enclosing `<text>` (svg2pptx reads
79
+ * `text@x/y`; dom-to-svg leaves those off and positions the tspan);
80
+ * - collapse `font-family="<stack>"` to its first concrete name (a PPTX
81
+ * typeface is one font name, not a CSS fallback list).
82
+ * Pure string transform exported for unit coverage.
83
+ */
84
+ export function preprocessSvg(svg: string): string {
85
+ let out = svg.replace(
86
+ /<text\b([^>]*)>(\s*)<tspan\b([^>]*)>/g,
87
+ (full, textAttrs, ws, tspanAttrs) => {
88
+ if (/\bx=/.test(textAttrs)) return full; // already positioned
89
+ const x = /\bx="([^"]*)"/.exec(tspanAttrs)?.[1];
90
+ const y = /\by="([^"]*)"/.exec(tspanAttrs)?.[1];
91
+ if (x == null || y == null) return full;
92
+ return `<text x="${x}" y="${y}"${textAttrs}>${ws}<tspan${tspanAttrs}>`;
122
93
  }
123
-
124
- // Single artboard.
125
- const outFile = path.join(tmp, `${baseSlug}.pptx`);
126
- await captureOne(target, ctx, outFile, timeoutSec, bundlePath);
127
- const bytes = new Uint8Array(readFileSync(outFile));
128
- return {
129
- filename: `${baseSlug}.pptx`,
130
- contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
131
- body: bytes,
132
- };
133
- } finally {
134
- rmSync(tmp, { recursive: true, force: true });
135
- }
94
+ );
95
+ out = out.replace(/font-family="([^"]*)"/g, (m, val: string) => {
96
+ const first = val
97
+ .split(',')[0]
98
+ .replace(/&quot;|["']/g, '')
99
+ .trim();
100
+ return first ? `font-family="${first}"` : m;
101
+ });
102
+ return out;
136
103
  }
137
104
 
138
- // Suppress unused.
139
- void path_dirname;
105
+ // ─── svg2pptx (Python) detection + invocation ─────────────────────────────────
140
106
 
141
107
  /**
142
- * Walk the live canvas page and return ordered `data-dc-screen` IDs.
143
- * Reuses the existing playwright shim path cheap because playwright keeps
144
- * its browser warm across requests, but still one extra navigation per
145
- * canvas-as-separate export. Worth caching long-term.
108
+ * argv prefix for svg2pptx. `MAUDE_SVG2PPTX` overrides (space-separated, e.g. a
109
+ * venv path or `python3 -m svg2pptx`); defaults to the `svg2pptx` CLI on PATH.
146
110
  */
147
- async function enumerateArtboards(
148
- target: Extract<Target, { kind: 'element' }>,
149
- ctx: ExportContext,
150
- timeoutSec: number
151
- ): Promise<string[]> {
152
- // Spawn `bin/_enumerate-artboards-playwright.mjs` via subprocess instead of
153
- // importing playwright directly — keeps chromium-bidi + playwright internals
154
- // out of the `bun build --compile` graph for the standalone server binary.
155
- const url = canvasShellUrl(ctx, target.file);
156
- const proc = Bun.spawn(
157
- ['node', ENUMERATE_PLAYWRIGHT, '--url', url, '--timeout', String(timeoutSec)],
158
- { cwd: path.dirname(ENUMERATE_PLAYWRIGHT), stdout: 'pipe', stderr: 'pipe' }
159
- );
111
+ function svg2pptxArgv(): string[] {
112
+ const env = process.env.MAUDE_SVG2PPTX?.trim();
113
+ return env ? env.split(/\s+/) : ['svg2pptx'];
114
+ }
115
+
116
+ let _svg2pptxAvailable: boolean | null = null;
117
+ async function svg2pptxAvailable(): Promise<boolean> {
118
+ if (_svg2pptxAvailable !== null) return _svg2pptxAvailable;
119
+ try {
120
+ const [bin, ...rest] = svg2pptxArgv();
121
+ const proc = Bun.spawn([bin, ...rest, '--version'], { stdout: 'pipe', stderr: 'pipe' });
122
+ _svg2pptxAvailable = (await proc.exited) === 0;
123
+ } catch {
124
+ _svg2pptxAvailable = false;
125
+ }
126
+ return _svg2pptxAvailable;
127
+ }
128
+
129
+ async function runSvg2pptx(inSvg: string, outPptx: string): Promise<void> {
130
+ const [bin, ...rest] = svg2pptxArgv();
131
+ const proc = Bun.spawn([bin, ...rest, inSvg, outPptx], { stdout: 'pipe', stderr: 'pipe' });
160
132
  const [stdout, stderr] = await Promise.all([
161
133
  new Response(proc.stdout).text(),
162
134
  new Response(proc.stderr).text(),
163
135
  ]);
164
136
  const code = await proc.exited;
165
137
  if (code !== 0) {
166
- throw new Error(
167
- `_enumerate-artboards-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`
168
- );
138
+ throw new Error(`svg2pptx exited ${code}: ${stderr.trim() || stdout.trim()}`);
169
139
  }
170
- return stdout
171
- .split('\n')
172
- .map((s) => s.trim())
173
- .filter(Boolean);
174
140
  }
175
141
 
142
+ // ─── deck merge (single-slide svg2pptx decks → one) ───────────────────────────
143
+
176
144
  /**
177
- * Concatenate slides from many single-slide PPTX files into one deck.
178
- * pptxgenjs has no merge API. We unpack each ZIP, rewrite slide refs, and
179
- * repack minimal OOXML twiddling because every dom-to-pptx output has the
180
- * same layout/master refs (the lib emits them deterministically).
145
+ * Merge N single-slide svg2pptx decks into one. svg2pptx output carries no
146
+ * media (pure native shapes) so there's no media-collision to namespace we
147
+ * keep the first deck's master/layout/theme skeleton, append each slide
148
+ * renumbered, and reconcile presentation.xml + its rels + Content-Types.
149
+ * Pure (in→out buffers); exported for unit coverage.
181
150
  */
182
- async function mergePptx(files: string[]): Promise<Uint8Array> {
183
- if (files.length === 0) return new Uint8Array(0);
184
- if (files.length === 1) return new Uint8Array(readFileSync(files[0]));
151
+ export async function mergeDecks(decks: Uint8Array[]): Promise<Uint8Array> {
152
+ if (decks.length === 0) return new Uint8Array(0);
153
+ if (decks.length === 1) return decks[0];
185
154
  const JSZip = (await import('jszip')).default;
186
- // Use the first file as the base keep its layout/master/theme/presentation
187
- // skeleton, replace its slide collection with the union from all inputs.
188
- const base = await JSZip.loadAsync(new Uint8Array(readFileSync(files[0])));
189
- const slides: Array<{ name: string; xml: string; rels: string }> = [];
190
-
191
- for (const file of files) {
192
- const zip = await JSZip.loadAsync(new Uint8Array(readFileSync(file)));
193
- const slideNames = Object.keys(zip.files).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n));
194
- for (const sn of slideNames) {
195
- const slideEntry = zip.file(sn);
196
- if (!slideEntry) continue;
197
- const xml = await slideEntry.async('string');
198
- const relsName = sn.replace('slides/', 'slides/_rels/').replace('.xml', '.xml.rels');
199
- const relsEntry = zip.file(relsName);
200
- const rels = relsEntry ? await relsEntry.async('string') : '';
201
- slides.push({ name: sn, xml, rels });
202
- }
203
- }
204
-
205
- // Rewrite slide filenames so they're contiguous.
155
+ const base = await JSZip.loadAsync(decks[0]);
206
156
  const next = new JSZip();
157
+
158
+ // Skeleton (master / layouts / theme / presProps / viewProps / docProps …),
159
+ // everything except the slides (re-emitted renumbered).
207
160
  for (const name of Object.keys(base.files)) {
208
- // `Object.keys(base.files)` includes directory entries — `base.file(name)`
209
- // returns null for those. Bare path-based filter first, then the entry
210
- // lookup with a defensive null-check.
211
161
  if (/\/$/.test(name)) continue;
212
- if (/^ppt\/slides\/(slide|_rels\/slide)\d+\.xml(\.rels)?$/.test(name)) continue;
162
+ if (/^ppt\/slides\//.test(name)) continue;
213
163
  const entry = base.file(name);
214
164
  if (!entry || entry.dir) continue;
215
165
  next.file(name, await entry.async('uint8array'));
216
166
  }
217
- // Insert the merged slide list.
218
- for (let i = 0; i < slides.length; i += 1) {
219
- const s = slides[i];
220
- const idx = i + 1;
221
- next.file(`ppt/slides/slide${idx}.xml`, s.xml);
222
- next.file(`ppt/slides/_rels/slide${idx}.xml.rels`, s.rels);
223
- }
224
- // Patch ppt/presentation.xml + its rels to reference the new slide count.
225
- // The original presentation.xml from the first file references slide1; for
226
- // merged decks > 1 slide we need to grow the sldIdLst. Done via simple
227
- // regex on the XML string.
228
- const presPath = 'ppt/presentation.xml';
229
- const presRelsPath = 'ppt/_rels/presentation.xml.rels';
230
- const presEntry = base.file(presPath);
231
- const presRelsEntry = base.file(presRelsPath);
232
- if (!presEntry || !presRelsEntry) {
233
- // Malformed input — fall through to first input untouched. Better than
234
- // throwing inside the merge for a quirk we can't recover from.
235
- return new Uint8Array(readFileSync(files[0]));
167
+
168
+ // Append each deck's slide(s), contiguous.
169
+ let count = 0;
170
+ for (const buf of decks) {
171
+ const zip = await JSZip.loadAsync(buf);
172
+ const slideNames = Object.keys(zip.files)
173
+ .filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n))
174
+ .sort();
175
+ for (const sn of slideNames) {
176
+ const slideEntry = zip.file(sn);
177
+ if (!slideEntry) continue;
178
+ count += 1;
179
+ next.file(`ppt/slides/slide${count}.xml`, await slideEntry.async('string'));
180
+ const relsName = `${sn.replace('ppt/slides/', 'ppt/slides/_rels/')}.rels`;
181
+ const relsEntry = zip.file(relsName);
182
+ if (relsEntry) {
183
+ next.file(`ppt/slides/_rels/slide${count}.xml.rels`, await relsEntry.async('string'));
184
+ }
185
+ }
236
186
  }
237
- const presXml = await presEntry.async('string');
238
- const presRels = await presRelsEntry.async('string');
239
- // sldIdLst: rebuild with N entries.
240
- const idEntries = slides
241
- .map((_, i) => `<p:sldId id="${256 + i}" r:id="rId${100 + i}"/>`)
242
- .join('');
243
- const newPresXml = presXml.replace(
187
+
188
+ // presentation.xml grow sldIdLst to N.
189
+ const presEntry = base.file('ppt/presentation.xml');
190
+ const presRelsEntry = base.file('ppt/_rels/presentation.xml.rels');
191
+ if (!presEntry || !presRelsEntry) return decks[0];
192
+ let pres = await presEntry.async('string');
193
+ const idEntries = Array.from(
194
+ { length: count },
195
+ (_, i) => `<p:sldId id="${256 + i}" r:id="rId${100 + i}"/>`
196
+ ).join('');
197
+ pres = pres.replace(
244
198
  /<p:sldIdLst>[\s\S]*?<\/p:sldIdLst>/,
245
199
  `<p:sldIdLst>${idEntries}</p:sldIdLst>`
246
200
  );
247
- next.file(presPath, newPresXml);
248
- // Build rels with N slide refs, keeping existing non-slide rels.
249
- const existingRels = presRels.match(/<Relationship[^/]*\/>/g) ?? [];
250
- const nonSlideRels = existingRels.filter(
251
- (r) => !/Type="http:\/\/[^"]*relationships\/slide"/.test(r)
252
- );
253
- const slideRels = slides
254
- .map(
255
- (_, i) =>
256
- `<Relationship Id="rId${100 + i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide${i + 1}.xml"/>`
257
- )
258
- .join('');
259
- const newPresRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${nonSlideRels.join('')}${slideRels}</Relationships>`;
260
- next.file(presRelsPath, newPresRels);
201
+ next.file('ppt/presentation.xml', pres);
202
+
203
+ // presentation.xml.rels STRIP slide rels (Type ending `/relationships/slide`,
204
+ // `[^>]*` so the `//` in the Type URL doesn't truncate the match), KEEP
205
+ // everything else (slideMaster/theme/presProps), append N slide rels.
206
+ let prels = await presRelsEntry.async('string');
207
+ prels = prels.replace(/<Relationship\b[^>]*Type="[^"]*\/relationships\/slide"[^>]*\/>/g, '');
208
+ const slideRels = Array.from(
209
+ { length: count },
210
+ (_, i) =>
211
+ `<Relationship Id="rId${100 + i}" Type="${SLIDE_REL}" Target="slides/slide${i + 1}.xml"/>`
212
+ ).join('');
213
+ prels = prels.replace(/<\/Relationships>\s*$/, `${slideRels}</Relationships>`);
214
+ next.file('ppt/_rels/presentation.xml.rels', prels);
215
+
216
+ // Content-Types — contiguous slide Overrides, then drop any Override pointing
217
+ // at a non-existent part (svg2pptx/pptxgenjs over-declare slideMaster<N> etc.).
218
+ const ctEntry = base.file('[Content_Types].xml');
219
+ if (ctEntry) {
220
+ let ct = await ctEntry.async('string');
221
+ ct = ct.replace(/<Override\s+PartName="\/ppt\/slides\/slide\d+\.xml"[^>]*\/>/g, '');
222
+ const slideOverrides = Array.from(
223
+ { length: count },
224
+ (_, i) => `<Override PartName="/ppt/slides/slide${i + 1}.xml" ContentType="${SLIDE_CT}"/>`
225
+ ).join('');
226
+ ct = ct.replace(/<\/Types>\s*$/, `${slideOverrides}</Types>`);
227
+ const parts = new Set(Object.keys(next.files).filter((n) => !n.endsWith('/')));
228
+ ct = ct.replace(/<Override\s+PartName="([^"]+)"[^>]*\/>/g, (full, p: string) =>
229
+ parts.has(p.replace(/^\//, '')) ? full : ''
230
+ );
231
+ next.file('[Content_Types].xml', ct);
232
+ }
261
233
 
262
234
  return next.generateAsync({ type: 'uint8array' });
263
235
  }
236
+
237
+ // ─── render helpers ───────────────────────────────────────────────────────────
238
+
239
+ function svgArgsFor(target: ElementTarget, url: string, outDir: string, bundle: string): string[] {
240
+ const args = [
241
+ SVG_PLAYWRIGHT,
242
+ '--url',
243
+ url,
244
+ '--selector',
245
+ target.cssPath,
246
+ '--bundle-path',
247
+ bundle,
248
+ '--timeout',
249
+ '20',
250
+ ];
251
+ if (target.multi) args.push('--multi', '1', '--out-dir', outDir);
252
+ else {
253
+ if (target.widen) args.push('--widen-to-artboard', '1');
254
+ args.push('--out', path.join(outDir, `${target.canvasSlug}.svg`));
255
+ }
256
+ return args;
257
+ }
258
+
259
+ function pngArgsFor(target: ElementTarget, url: string, outDir: string): string[] {
260
+ const args = [
261
+ PNG_PLAYWRIGHT,
262
+ '--url',
263
+ url,
264
+ '--selector',
265
+ target.cssPath,
266
+ '--scale',
267
+ String(FALLBACK_SCALE),
268
+ '--timeout',
269
+ '20',
270
+ ];
271
+ if (target.multi) args.push('--multi', '1', '--out-dir', outDir);
272
+ else {
273
+ if (target.widen) args.push('--widen-to-artboard', '1');
274
+ args.push('--out', path.join(outDir, `${target.canvasSlug}.png`));
275
+ }
276
+ return args;
277
+ }
278
+
279
+ // ─── PNG fallback deck ────────────────────────────────────────────────────────
280
+
281
+ async function buildPngDeck(pngPaths: string[]): Promise<Uint8Array> {
282
+ const JSZip = (await import('jszip')).default;
283
+ // Slide size from the first PNG (px → inches); contain-fit the rest.
284
+ const dims = pngPaths.map((p) => {
285
+ // PNG IHDR: width @ byte 16, height @ 20 (big-endian).
286
+ const b = readFileSync(p);
287
+ return { w: b.readUInt32BE(16), h: b.readUInt32BE(20), p };
288
+ });
289
+ const maxW = Math.max(...dims.map((d) => d.w));
290
+ const maxH = Math.max(...dims.map((d) => d.h));
291
+ // The PNGs are FALLBACK_SCALE× the artboard; normalise to artboard inches.
292
+ const slideW = maxW / SCREEN_DPI / FALLBACK_SCALE;
293
+ const slideH = maxH / SCREEN_DPI / FALLBACK_SCALE;
294
+ const pptx = new PptxGenJS();
295
+ pptx.defineLayout({ name: 'MAUDE_ARTBOARD', width: slideW, height: slideH });
296
+ pptx.layout = 'MAUDE_ARTBOARD';
297
+ for (const d of dims) {
298
+ const slide = pptx.addSlide();
299
+ const nativeW = d.w / SCREEN_DPI / FALLBACK_SCALE;
300
+ const nativeH = d.h / SCREEN_DPI / FALLBACK_SCALE;
301
+ const scale = Math.min(slideW / nativeW, slideH / nativeH);
302
+ const w = nativeW * scale;
303
+ const h = nativeH * scale;
304
+ const dataUri = `data:image/png;base64,${readFileSync(d.p).toString('base64')}`;
305
+ slide.addImage({ data: dataUri, x: (slideW - w) / 2, y: (slideH - h) / 2, w, h });
306
+ }
307
+ const buf = new Uint8Array((await pptx.write({ outputType: 'nodebuffer' })) as Uint8Array);
308
+ // Strip pptxgenjs's phantom slideMaster<N> Content-Types Overrides.
309
+ const zip = await JSZip.loadAsync(buf);
310
+ const ctEntry = zip.file('[Content_Types].xml');
311
+ if (ctEntry) {
312
+ const parts = new Set(Object.keys(zip.files).filter((n) => !n.endsWith('/')));
313
+ let ct = await ctEntry.async('string');
314
+ ct = ct.replace(/<Override\s+PartName="([^"]+)"[^>]*\/>/g, (full, p: string) =>
315
+ parts.has(p.replace(/^\//, '')) ? full : ''
316
+ );
317
+ zip.file('[Content_Types].xml', ct);
318
+ }
319
+ return zip.generateAsync({ type: 'uint8array' });
320
+ }
321
+
322
+ export async function run(
323
+ targets: Target[],
324
+ options: ExportOptions,
325
+ ctx: ExportContext
326
+ ): Promise<ExportResult> {
327
+ const empty: ExportResult = {
328
+ filename: 'export.pptx',
329
+ contentType: PPTX_MIME,
330
+ body: new Uint8Array(0),
331
+ };
332
+ if (!targets.length) return empty;
333
+ const elementTargets = targets.filter(isElementTarget);
334
+ if (!elementTargets.length) {
335
+ throw new Error('pptx adapter requires element targets (got file-tree)');
336
+ }
337
+ // `options.raster` forces the PNG (non-editable) path even when svg2pptx exists.
338
+ const forceRaster = options.raster === true || options.mode === 'raster';
339
+ const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
340
+ const tmp = mkdtempSync(path.join(tmpdir(), 'maude-pptx-'));
341
+ const svgDir = path.join(tmp, 'svg');
342
+ const pngDir = path.join(tmp, 'png');
343
+ mkdirSync(svgDir, { recursive: true });
344
+ mkdirSync(pngDir, { recursive: true });
345
+
346
+ try {
347
+ const useNative = !forceRaster && (await svg2pptxAvailable());
348
+
349
+ if (useNative) {
350
+ try {
351
+ const bundle = await getBrowserBundle('dom-to-svg', 'domToSvg');
352
+ const svgFiles: string[] = [];
353
+ for (const t of elementTargets) {
354
+ svgFiles.push(
355
+ ...(await spawnShim(svgArgsFor(t, canvasShellUrl(ctx, t.file), svgDir, bundle)))
356
+ );
357
+ }
358
+ if (!svgFiles.length) return empty;
359
+ const deckBuffers: Uint8Array[] = [];
360
+ for (let i = 0; i < svgFiles.length; i += 1) {
361
+ const pre = path.join(svgDir, `slide-${i + 1}.pre.svg`);
362
+ writeFileSync(pre, preprocessSvg(readFileSync(svgFiles[i], 'utf8')));
363
+ const outPptx = path.join(svgDir, `slide-${i + 1}.pptx`);
364
+ await runSvg2pptx(pre, outPptx);
365
+ deckBuffers.push(new Uint8Array(readFileSync(outPptx)));
366
+ }
367
+ const body = await mergeDecks(deckBuffers);
368
+ return { filename: `${baseSlug}.pptx`, contentType: PPTX_MIME, body };
369
+ } catch (err) {
370
+ // svg2pptx present but failed on this canvas — fall through to PNG so
371
+ // the export still succeeds, with a diagnostic.
372
+ const msg = err instanceof Error ? err.message : String(err);
373
+ console.error(` warn: svg2pptx native PPTX failed, falling back to PNG: ${msg}`);
374
+ }
375
+ } else if (!forceRaster) {
376
+ console.error(
377
+ ' note: svg2pptx not found — exporting a (non-editable) PNG deck. For editable native PowerPoint objects: `pip install svg2pptx` (needs python3).'
378
+ );
379
+ }
380
+
381
+ // PNG fallback (faithful, universal, Canva-safe, not editable).
382
+ const pngFiles: string[] = [];
383
+ for (const t of elementTargets) {
384
+ pngFiles.push(...(await spawnShim(pngArgsFor(t, canvasShellUrl(ctx, t.file), pngDir))));
385
+ }
386
+ if (!pngFiles.length) return empty;
387
+ const body = await buildPngDeck(pngFiles);
388
+ return { filename: `${baseSlug}.pptx`, contentType: PPTX_MIME, body };
389
+ } finally {
390
+ rmSync(tmp, { recursive: true, force: true });
391
+ }
392
+ }