slidemind 0.3.17 → 0.3.19

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidemind",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "description": "Connect Claude Code to the slidemind PowerPoint add-in — build and edit native, on-brand PowerPoint decks against the active corporate design, from the terminal.",
5
5
  "author": {
6
6
  "name": "statworx",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidemind",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "description": "Connect Codex to the slidemind PowerPoint add-in — build and edit native, on-brand PowerPoint decks against the active corporate design. Requires the slidemind CLI (npm install -g slidemind); the skills explain the install when it is missing.",
5
5
  "author": {
6
6
  "name": "statworx",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidemind",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "description": "Connect Claude Code, Codex & opencode to the slidemind PowerPoint add-in — let your coding agent build and edit native, on-brand PowerPoint decks against your active corporate design, from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,10 +37,11 @@ import {
37
37
  writeDeckBundleRef,
38
38
  } from "./bundle.mjs";
39
39
  import { bundleHasCaptureEngine, captureDeckLocally } from "./capture.mjs";
40
+ import { lintConvertedOrThrow } from "../lib/lint-converted.mjs";
40
41
  import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
41
42
  import {
42
43
  applyConversionGates,
43
- conversionsMissingPreserveSource,
44
+ conversionIntegrityErrors,
44
45
  loadConversions,
45
46
  rehomeConversions,
46
47
  } from "../lib/conversions.mjs";
@@ -274,12 +275,25 @@ export async function renderDeckToBase64(
274
275
  // server splices them back from. Keyed by slide file name, like `slides`.
275
276
  const conversions = loadConversions(deckDir);
276
277
  const hasConversions = Object.keys(conversions).length > 0;
277
- for (const file of conversionsMissingPreserveSource(conversions, deckDir)) {
278
- process.stderr.write(
279
- `note: ${file} has preserved native objects or a sourced chart but no ` +
280
- `conversions/*.slide.pptx to read them from — re-run 'slidemind adopt' for that deck\n`,
278
+ // A record that points at a package file that is gone, or still names the
279
+ // file it was renamed away from, used to be a note AFTER the render — by
280
+ // which time the slide had already come back on a fallback layout. It is a
281
+ // hard stop: the render request would carry a lie about the source.
282
+ const integrity = conversionIntegrityErrors(deckDir, conversions);
283
+ if (integrity.length > 0) {
284
+ throw new Error(
285
+ `conversion records are out of sync with the deck:\n ${integrity.join("\n ")}`,
281
286
  );
282
287
  }
288
+ // The export gate for the converted dialect. `view-draft` prints these as
289
+ // warnings so a half-authored deck still previews; here — the last step
290
+ // before a .pptx exists — a surviving master prompt, copy in a field frame
291
+ // or text spilling a fixed box stops the deck from shipping.
292
+ if (hasConversions) {
293
+ lintConvertedOrThrow({
294
+ slideFiles: Object.keys(conversions).map((f) => path.join(deckDir, "slides", f)),
295
+ });
296
+ }
283
297
 
284
298
  const bundleDir = resolveBundleDirForDeck(deckDir);
285
299
  // Local capture only applies to authored JSX slides. A native-only deck (just
@@ -544,19 +558,16 @@ export async function viewDraft(
544
558
  }
545
559
  const warnings = [];
546
560
  // Same identity reconcile as the render path — a reorder renames every file,
547
- // and a stale sidecar here would mislabel renamed converted slides (Chrome
548
- // would then choke on their unregistered ConvertedHost layout).
561
+ // and a stale sidecar here would mislabel renamed converted slides.
549
562
  warnings.push(...rehomeConversions(deckDir, reconcileSlideGuids(deckDir)));
550
563
  // Converted slides (`slidemind adopt`) invoke the synthetic ConvertedHost
551
- // layout, which no materialized bundle carries — the local capture engine
552
- // builds its layout registry from the bundle dir, so it cannot render them.
553
- // They are named here instead of surfacing as a cryptic unregistered-layout
554
- // error, and a deck of nothing but converted slides never launches Chrome.
564
+ // layout, which no materialized bundle carries — buildCaptureInput feeds the
565
+ // engine a stand-in host per source canvas so they preview here like any
566
+ // other slide. 34 converted slides were once authored blind because this
567
+ // refused them.
555
568
  const conversions = loadConversions(deckDir, { includeSource: false });
556
- const convertedNote =
557
- "converted slide — renders server-side only (its ConvertedHost layout is not in the " +
558
- "bundle); use `slidemind build-pptx <deck>` to look at it";
559
- const hasJsx = all.some((f) => f.endsWith(".jsx") && !conversions[f]);
569
+ const convertedNote = "converted slide — geometry authoritative on the server render";
570
+ const hasJsx = all.some((f) => f.endsWith(".jsx"));
560
571
 
561
572
  // Resolve the materialized bundle. Local capture needs the bundle's capture
562
573
  // engine; once it's cached this whole call is offline. Only reach for a host
@@ -593,19 +604,14 @@ export async function viewDraft(
593
604
  bundleDir = resolveBundleDirForDeck(deckDir);
594
605
  }
595
606
 
596
- // Nothing left to capture — a native-only deck (just reused .pptx), a fully
597
- // converted one, or both.
607
+ // Nothing left to capture — a native-only deck (just reused .pptx).
598
608
  if (!hasJsx) {
599
609
  return {
600
- slides: all.map((fileName) =>
601
- conversions[fileName]
602
- ? { fileName, status: "converted", note: convertedNote }
603
- : {
604
- fileName,
605
- status: "native",
606
- note: "reused native slide — not authored JSX, no draft preview rendered",
607
- },
608
- ),
610
+ slides: all.map((fileName) => ({
611
+ fileName,
612
+ status: "native",
613
+ note: "reused native slide — not authored JSX, no draft preview rendered",
614
+ })),
609
615
  warnings,
610
616
  };
611
617
  }
@@ -621,16 +627,13 @@ export async function viewDraft(
621
627
 
622
628
  let captured;
623
629
  try {
624
- // Converted slides never reach the local engine — it would lint each one
625
- // as an unregistered layout and the noise would ride the translate gate.
626
- const capturable = all.filter((f) => f.endsWith(".jsx") && !conversions[f]);
630
+ const capturable = all.filter((f) => f.endsWith(".jsx"));
627
631
  captured = await captureDeckLocally({ deckDir, bundleDir, onProgress, only: capturable });
628
632
  } catch (err) {
629
633
  throw new Error(
630
634
  `local draft capture failed (${err instanceof Error ? err.message : String(err)}). ` +
631
635
  "It runs a local headless Chrome; if Chrome can't launch here, " +
632
- "`slidemind review <deck>` still builds and serves the HTML preview without it." +
633
- (Object.keys(conversions).length > 0 ? ` ${convertedNote}.` : ""),
636
+ "`slidemind review <deck>` still builds and serves the HTML preview without it.",
634
637
  );
635
638
  }
636
639
 
@@ -643,10 +646,9 @@ export async function viewDraft(
643
646
  note: "reused native slide — not authored JSX, no draft preview rendered",
644
647
  };
645
648
  }
646
- if (conversions[fileName]) {
647
- return { fileName, status: "converted", note: convertedNote };
648
- }
649
- return mapCapturedSlide(fileName, byFile.get(fileName), warnings);
649
+ const slide = mapCapturedSlide(fileName, byFile.get(fileName), warnings);
650
+ if (conversions[fileName] && !slide.note) slide.note = convertedNote;
651
+ return slide;
650
652
  });
651
653
 
652
654
  // --translate: run the captured slides through the server's translate gate
@@ -658,10 +660,18 @@ export async function viewDraft(
658
660
  // `translate: "auto"` (the default) runs the gate when it can and reports
659
661
  // why not otherwise — an unbound deck or a dead backend must not turn a
660
662
  // host-free draft check into a failure; `true` (--translate) insists.
663
+ // Converted slides are captured for the PICTURE only. Their export gate is
664
+ // the server render with their conversion record, and the stand-in host they
665
+ // preview on names no brand layout — sending them through /translate would
666
+ // judge them against a layout contract the CLI invented.
667
+ const gateDeck = {
668
+ ...captured,
669
+ slides: (captured?.slides ?? []).filter((s) => !conversions[s.fileName]),
670
+ };
661
671
  let translateErrors = null;
662
672
  let translateWarnings = [];
663
673
  let translateRan = false;
664
- if (translate && captured?.slides?.length) {
674
+ if (translate && gateDeck.slides.length) {
665
675
  const skip = (why) => {
666
676
  if (translate !== "auto") throw new Error(why);
667
677
  warnings.push(`export gate skipped: ${why}`);
@@ -684,7 +694,7 @@ export async function viewDraft(
684
694
  brandId: ref.presFP,
685
695
  backendURL: mat.backendURL,
686
696
  deckName: path.basename(deckDir),
687
- captured,
697
+ captured: gateDeck,
688
698
  etag: mat.etag,
689
699
  });
690
700
  translateErrors = new Map();
@@ -1054,6 +1064,48 @@ export async function buildAndPush({
1054
1064
  };
1055
1065
  }
1056
1066
 
1067
+ // After a successful build-pptx: per-slide PNGs of what was just shipped, into
1068
+ // `<deck>/.build-preview/NN-name.png`. The .pptx itself is opaque here, so
1069
+ // without this the agent hands over a deck it has never seen. Best-effort — a
1070
+ // missing engine or a Chrome that won't launch returns a note, never a failure:
1071
+ // the .pptx is already written.
1072
+ export async function writeBuildPreview(deckDir, onProgress = null) {
1073
+ const bundleDir = resolveBundleDirForDeck(deckDir);
1074
+ if (!bundleHasCaptureEngine(bundleDir)) {
1075
+ return {
1076
+ dir: null,
1077
+ files: [],
1078
+ note:
1079
+ "visual check skipped — the deck's bundle carries no capture engine " +
1080
+ `(run 'slidemind download-bundle ${path.basename(deckDir)}')`,
1081
+ };
1082
+ }
1083
+ let captured;
1084
+ try {
1085
+ captured = await captureDeckLocally({ deckDir, bundleDir, onProgress });
1086
+ } catch (err) {
1087
+ return {
1088
+ dir: null,
1089
+ files: [],
1090
+ note: `visual check skipped — local capture failed (${err instanceof Error ? err.message : String(err)})`,
1091
+ };
1092
+ }
1093
+ const dir = path.join(deckDir, ".build-preview");
1094
+ fs.mkdirSync(dir, { recursive: true });
1095
+ const files = [];
1096
+ for (const slide of captured?.slides ?? []) {
1097
+ const b64 =
1098
+ /^data:image\/png;base64,(.+)$/.exec(slide.previewDataUrl ?? "")?.[1] ??
1099
+ slide.render?.fallbackPngBase64 ??
1100
+ null;
1101
+ if (!b64) continue;
1102
+ const file = path.join(dir, `${path.basename(slide.fileName, ".jsx")}.png`);
1103
+ fs.writeFileSync(file, Buffer.from(b64, "base64"));
1104
+ files.push(file);
1105
+ }
1106
+ return { dir, files, note: null };
1107
+ }
1108
+
1057
1109
  // Render a deck to a .pptx file (the `slidemind build-pptx` path). For a BOUND
1058
1110
  // deck against a backend serving brand routes this is fully pane-free — no
1059
1111
  // PowerPoint window needed at all (backgrounded or closed). Otherwise it needs
@@ -1092,6 +1144,7 @@ export async function buildPptxFile({
1092
1144
  slideCount: detached.result.slideCount ?? null,
1093
1145
  warnings: detached.result.warnings ?? [],
1094
1146
  skippedSlides: detached.result.skippedSlides ?? [],
1147
+ preview: await writeBuildPreview(deckDir, onProgress),
1095
1148
  };
1096
1149
  }
1097
1150
  }
@@ -1115,5 +1168,6 @@ export async function buildPptxFile({
1115
1168
  slideCount: result.slideCount ?? null,
1116
1169
  warnings: result.warnings ?? [],
1117
1170
  skippedSlides: result.skippedSlides ?? [],
1171
+ preview: await writeBuildPreview(deckDir, onProgress),
1118
1172
  };
1119
1173
  }
@@ -14,6 +14,8 @@ import fs from "node:fs";
14
14
  import path from "node:path";
15
15
  import { pathToFileURL } from "node:url";
16
16
 
17
+ import { convertedHostLayouts, loadConversions } from "../lib/conversions.mjs";
18
+
17
19
  // Same launch profile as the add-in's chrome-launcher.ts, so the client browser
18
20
  // behaves identically to the server's during capture.
19
21
  const LAUNCH_ARGS = [
@@ -88,6 +90,15 @@ export function buildCaptureInput(deckDir, bundleDir) {
88
90
 
89
91
  const slides = readJsxDir(path.join(deckDir, "slides"));
90
92
  const layouts = readJsxDir(path.join(bundleDir, "layouts"));
93
+ // Converted slides (`slidemind adopt`) invoke a ConvertedHost layout the
94
+ // server mints per source canvas — no bundle carries it, so without a
95
+ // stand-in the engine rejects every one of them as an unregistered layout
96
+ // and the agent authors them blind. The stub is the deck's own host box;
97
+ // geometry stays authoritative on the server render.
98
+ Object.assign(
99
+ layouts,
100
+ convertedHostLayouts(deckDir, loadConversions(deckDir, { includeSource: false })),
101
+ );
91
102
 
92
103
  const layoutsCssPath = path.join(bundleDir, "layouts", "layouts.css");
93
104
  const layoutsCss = fs.existsSync(layoutsCssPath)
@@ -37,8 +37,9 @@ import { resolveBundleDirForDeck } from "../bridge/bundle.mjs";
37
37
  import { loadChartPreview, loadConnectorPreview } from "../bridge/capture.mjs";
38
38
  import { FAVICON_LINK } from "./gate-server.mjs";
39
39
  import { lintSlidesOrThrow } from "./lint.mjs";
40
+ import { lintConvertedOrThrow } from "../lib/lint-converted.mjs";
40
41
  import { resolveSkillForDeck } from "./resolve-skill.mjs";
41
- import { loadConversions } from "../lib/conversions.mjs";
42
+ import { convertedHostLayouts, loadConversions } from "../lib/conversions.mjs";
42
43
  import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
43
44
 
44
45
  const __filename = fileURLToPath(import.meta.url);
@@ -88,26 +89,13 @@ function inlineLayout(file) {
88
89
  );
89
90
  }
90
91
 
91
- // Preview stand-ins for the ConvertedHost layouts converted slides invoke. The
92
- // host's name carries its canvas (`ConvertedHost<cx>x<cy>`, EMU), so the stub
93
- // sizes itself the way the minted host does (EMU / 9525, 3 decimals) instead of
94
- // assuming 16:9 — a 4:3 source previews at 960x720, not stretched to 1280.
95
- // Real geometry still comes from the server render.
96
- const HOST_NAME_RE = /<(ConvertedHost(\d+)x(\d+))/;
97
- const emuToCssPx = (emu) => `${Math.round((Number(emu) / 9525) * 1000) / 1000}px`;
98
-
99
- function convertedHostStubs(slideFiles, conversions) {
100
- const hosts = new Map();
101
- for (const file of slideFiles) {
102
- if (!conversions[path.basename(file)]) continue;
103
- const m = HOST_NAME_RE.exec(fs.readFileSync(file, "utf8"));
104
- if (m) hosts.set(m[1], { w: emuToCssPx(m[2]), h: emuToCssPx(m[3]) });
105
- }
106
- return [...hosts].map(
107
- ([name, { w, h }]) =>
108
- `<script type="text/jsx-template" data-kind="layout" data-name="${name}" data-binding="${name}">\n` +
109
- `function ${name}({ background, children }) { return <div style={{ position: "relative", width: "${w}", height: "${h}", overflow: "hidden", background: background || "#FFFFFF" }}>{children}</div>; }\n` +
110
- `</script>`,
92
+ // Preview stand-ins for the ConvertedHost layouts converted slides invoke —
93
+ // same sources the local capture path feeds its engine (src/lib/conversions.mjs),
94
+ // wrapped as inline jsx-templates for this page.
95
+ function convertedHostStubs(deckDir, conversions) {
96
+ return Object.entries(convertedHostLayouts(deckDir, conversions)).map(
97
+ ([name, source]) =>
98
+ `<script type="text/jsx-template" data-kind="layout" data-name="${name.replace(/\.jsx$/, "")}" data-binding="${name.replace(/\.jsx$/, "")}">\n${source}</script>`,
111
99
  );
112
100
  }
113
101
 
@@ -381,12 +369,24 @@ export async function buildDeck(deckDir, opts = {}) {
381
369
  // Converted slides (`slidemind adopt`) invoke a synthetic ConvertedHost
382
370
  // layout the server mints per source canvas; no bundle carries it, so the
383
371
  // local engine would fail every one of them as an unregistered layout. They
384
- // are linted server-side on render — skip them here, lint the authored rest.
372
+ // get the dialect's OWN lint instead (master prompts that survived, empty
373
+ // placeholders, text in a field frame, text spilling a fixed box) — without
374
+ // it the adopted half of a deck was checked by nothing at all.
385
375
  const conversions = loadConversions(deckDir, { includeSource: false });
376
+ const convertedFiles = slideFiles.filter((f) => conversions[path.basename(f)]);
386
377
  const authoredFiles = slideFiles.filter((f) => !conversions[path.basename(f)]);
387
378
  if (authoredFiles.length > 0) {
388
379
  await lintSlidesOrThrow({ deckDir, slideFiles: authoredFiles, layoutSources });
389
380
  }
381
+ if (convertedFiles.length > 0) {
382
+ lintConvertedOrThrow({
383
+ slideFiles: convertedFiles,
384
+ strict: Boolean(opts.strict),
385
+ // view-draft looks at half-authored slides, so it passes false; the
386
+ // review gate and the render path enforce.
387
+ enforce: opts.enforceConverted !== false,
388
+ });
389
+ }
390
390
 
391
391
  // The <Chart> stand-in runtime rides in the same bundle cache (see
392
392
  // loadChartPreview) — resolved after lint, which already proved the bundle
@@ -398,7 +398,7 @@ export async function buildDeck(deckDir, opts = {}) {
398
398
 
399
399
  const layoutsBlock = layoutFiles
400
400
  .map(inlineLayout)
401
- .concat(convertedHostStubs(slideFiles, conversions))
401
+ .concat(convertedHostStubs(deckDir, conversions))
402
402
  .join("\n");
403
403
  const slidesBlock = slideFiles
404
404
  .map((file, i) => inlineSlide(file, i))
package/src/cli/deck.mjs CHANGED
@@ -29,9 +29,24 @@ export async function cmdImagen(rest) {
29
29
  let outName = null;
30
30
  let size = null;
31
31
  let quality = null;
32
+ let into = null;
33
+ let behind = null;
34
+ let rect = null;
32
35
  for (let i = 0; i < rest.length; i++) {
33
36
  const a = rest[i];
34
- if (a === "--deck") {
37
+ if (a === "--into") {
38
+ into = rest[++i];
39
+ if (!into) fail("--into expects a slide file (e.g. slides/07-approach.jsx)");
40
+ } else if (a === "--behind") {
41
+ behind = rest[++i];
42
+ if (!/^\d+$/.test(behind ?? "")) fail("--behind expects a data-sm-source-id number");
43
+ } else if (a === "--rect") {
44
+ const parts = String(rest[++i] ?? "").split(",").map((n) => n.trim());
45
+ if (parts.length !== 4 || parts.some((n) => !/^-?[\d.]+$/.test(n))) {
46
+ fail("--rect expects <left,top,width,height> in px");
47
+ }
48
+ rect = { left: parts[0], top: parts[1], width: parts[2], height: parts[3] };
49
+ } else if (a === "--deck") {
35
50
  deck = rest[++i];
36
51
  if (!deck) fail("--deck expects a deck");
37
52
  } else if (a === "--out" || a === "-o") {
@@ -58,9 +73,12 @@ export async function cmdImagen(rest) {
58
73
  if (!prompt || !prompt.trim() || !deck) {
59
74
  fail(
60
75
  'usage: slidemind imagen "<prompt>" --deck <deck> [--out <name.png>] ' +
61
- "[--size auto|1024x1024|1536x1024|1024x1536] [--quality low|medium|high|auto]",
76
+ "[--size auto|1024x1024|1536x1024|1024x1536] [--quality low|medium|high|auto] " +
77
+ "[--into slides/NN-name.jsx --behind <source-id>|--rect <l,t,w,h>]",
62
78
  );
63
79
  }
80
+ if ((behind || rect) && !into) fail("--behind/--rect need --into <slide file>");
81
+ if (into && !behind && !rect) fail("--into needs --behind <source-id> or --rect <l,t,w,h>");
64
82
  const deckDir = resolveDeckArg(deck);
65
83
  const bundleMod = await import(
66
84
  pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
@@ -106,9 +124,37 @@ export async function cmdImagen(rest) {
106
124
  }
107
125
  const outPath = path.join(assetsDir, file);
108
126
  fs.writeFileSync(outPath, Buffer.from(m[2], "base64"));
127
+ process.stdout.write(`Wrote ${path.relative(process.cwd(), outPath)}\n`);
128
+ if (!into) {
129
+ process.stdout.write(
130
+ ` Reference it from slide props as "assets/${file}" (e.g. summaryImage, illustration),\n` +
131
+ ` or re-run with --into <slide> --behind <source-id> to place it for you.\n`,
132
+ );
133
+ return;
134
+ }
135
+ // Place it: a picture the agent wires up by hand is the step that put the
136
+ // last one on the wrong element.
137
+ const slidePath = path.isAbsolute(into)
138
+ ? into
139
+ : path.join(deckDir, into.startsWith("slides/") ? into : path.join("slides", into));
140
+ if (!fs.existsSync(slidePath)) fail(`--into: no such slide file: ${slidePath}`);
141
+ const { insertImageDiv } = await import(
142
+ pathToFileURL(path.join(SRC_DIR, "lib", "jsx-insert.mjs")).href
143
+ );
144
+ let next;
145
+ try {
146
+ next = insertImageDiv(fs.readFileSync(slidePath, "utf8"), {
147
+ url: `./assets/${file}`,
148
+ behind,
149
+ rect,
150
+ });
151
+ } catch (err) {
152
+ fail(`${err.message}\n The image is on disk at assets/${file} — place it by hand.`);
153
+ }
154
+ fs.writeFileSync(slidePath, next);
109
155
  process.stdout.write(
110
- `Wrote ${path.relative(process.cwd(), outPath)}\n` +
111
- ` Reference it from slide props as "assets/${file}" (e.g. summaryImage, illustration).\n`,
156
+ ` Placed it in ${path.relative(process.cwd(), slidePath)} ` +
157
+ `${behind ? `behind data-sm-source-id={${behind}}` : "at the given rect"} — re-read the file before patching it.\n`,
112
158
  );
113
159
  }
114
160
 
@@ -301,13 +347,21 @@ export async function cmdBuildPptx(rest) {
301
347
  .catch((err) => fail(withUnboundHint(deckDir, err)));
302
348
  if (asJson) {
303
349
  process.stdout.write(
304
- `${JSON.stringify({ outPath: result.outPath, slideCount: result.slideCount ?? null, presId: result.presId ?? null, windowless: Boolean(result.windowless), warnings: result.warnings ?? [], skippedSlides: result.skippedSlides ?? [] }, null, 2)}\n`,
350
+ `${JSON.stringify({ outPath: result.outPath, slideCount: result.slideCount ?? null, presId: result.presId ?? null, windowless: Boolean(result.windowless), warnings: result.warnings ?? [], skippedSlides: result.skippedSlides ?? [], preview: result.preview ?? null }, null, 2)}\n`,
305
351
  );
306
352
  } else {
307
353
  process.stdout.write(
308
354
  `Wrote ${result.outPath} (${result.slideCount ?? "?"} slides) via the slidemind add-in` +
309
355
  `${result.windowless ? " (no PowerPoint window needed)" : ""}\n`,
310
356
  );
357
+ // LOOK at what shipped — the .pptx is opaque from here.
358
+ if (result.preview?.dir) {
359
+ process.stdout.write(
360
+ ` ${result.preview.files.length} slide png(s) in ${path.relative(process.cwd(), result.preview.dir)}/ — read them before handing the deck over\n`,
361
+ );
362
+ } else if (result.preview?.note) {
363
+ process.stdout.write(` ${result.preview.note}\n`);
364
+ }
311
365
  }
312
366
  for (const w of result.warnings ?? []) {
313
367
  process.stderr.write(` warning: ${w}\n`);
@@ -331,10 +385,15 @@ export async function cmdViewDraft(rest) {
331
385
  let fix = false;
332
386
  let lintOnly = false;
333
387
  let asJson = false;
388
+ // Converted slides only: promote the dialect lint's warnings (an empty
389
+ // title/body placeholder) to errors.
390
+ let strict = false;
334
391
  for (let i = 0; i < passthrough.length; i++) {
335
392
  const a = passthrough[i];
336
393
  if (a === "--fix") {
337
394
  fix = true;
395
+ } else if (a === "--strict") {
396
+ strict = true;
338
397
  } else if (a === "--lint-only") {
339
398
  lintOnly = true;
340
399
  } else if (a === "--json") {
@@ -399,7 +458,9 @@ export async function cmdViewDraft(rest) {
399
458
  );
400
459
  }
401
460
  }
402
- await buildDeck(deckDir);
461
+ // Advisory here: the converted rules print as warnings while slides are
462
+ // still being edited, and block at `review` / `build-pptx`. --strict gates now.
463
+ await buildDeck(deckDir, { strict, enforceConverted: strict });
403
464
  if (lintOnly) {
404
465
  if (asJson) process.stdout.write(`${JSON.stringify({ deck: path.basename(deckDir), lint: "clean" })}\n`);
405
466
  else process.stdout.write("Lint clean (no render — drop --lint-only for the draft previews).\n");
package/src/cli/help.mjs CHANGED
@@ -63,6 +63,7 @@ export function printHelp(cmd = null) {
63
63
  ` --out <path> output path (default <deck>/<deck>.pptx)\n` +
64
64
  ` --force discard manual PowerPoint edits instead of reconciling them\n` +
65
65
  ` --json machine-readable result\n` +
66
+ ` (writes per-slide PNGs of the built deck to <deck>/.build-preview/)\n` +
66
67
  ` slidemind inspect-template-deck <deck> Inspect a template deck as JSON for pack-owned validation\n` +
67
68
  ` --pack <dir> explicit template-pack directory (required)\n` +
68
69
  ` --allow-unrendered-freeform include JSX inserts without .pptx artifacts\n` +
@@ -76,6 +77,7 @@ export function printHelp(cmd = null) {
76
77
  ` --only <file|N>[,…] render just these slides (a per-slide builder's own check)\n` +
77
78
  ` --fix apply the deterministic lint autofixes first\n` +
78
79
  ` --lint-only lint + HTML build, no render (fast fix loop)\n` +
80
+ ` --strict converted slides: gate here like review does (they only warn by default)\n` +
79
81
  ` --no-translate skip the export gate (purely local check)\n` +
80
82
  ` --translate insist on the export gate (fail instead of skipping when the backend is unreachable)\n` +
81
83
  ` --json machine-readable result (slides, defects, reviewCanOpen)\n` +
@@ -144,6 +146,9 @@ export function printHelp(cmd = null) {
144
146
  ` --out <name> file name under assets/ (default imagen-N.png)\n` +
145
147
  ` --size <s> auto|1024x1024|1536x1024|1024x1536 (default auto)\n` +
146
148
  ` --quality <q> low|medium|high|auto\n` +
149
+ ` --into <slide> place it in this slide file instead of only writing the asset\n` +
150
+ ` --behind <id> ... as a background div right before data-sm-source-id={id} (takes its rect)\n` +
151
+ ` --rect <l,t,w,h> ... at this rect in px instead\n` +
147
152
  ` slidemind download-bundle [deck] Materialize or refresh a corporate design's bundle locally\n` +
148
153
  ` (pane-free for a bound deck, --brand or --all — no PowerPoint needed)\n` +
149
154
  ` --to <presId> target a specific window (forces the pane route)\n` +
package/src/lib/adopt.mjs CHANGED
@@ -30,7 +30,14 @@ import { decodeSourceTags, mintSlideGuid, readSlideTags } from "../pptx/tags.mjs
30
30
  import { foldPickedSlides } from "../pptx/slide-swap.mjs";
31
31
  import { readDeckBundleRef, writeDeckBundleRef } from "../bridge/bundle.mjs";
32
32
  import { loadSlideGuids, saveSlideGuids, slideGuidEntry } from "./slide-guids.mjs";
33
- import { hasSourcedChart, writeConversionRecord } from "./conversions.mjs";
33
+ import { writeConversionRecord } from "./conversions.mjs";
34
+
35
+ // Converted JSX needs a fresh baseline against the imported PPTX. Source tags
36
+ // alone cannot recover the original conversion evidence or preserved natives.
37
+ export function needsConversion(decoded) {
38
+ return !decoded || /<ConvertedHost\d+x\d+\b/.test(decoded.jsx) ||
39
+ /^\s*\/\/\s*SLIDEMIND_DIALECT:\s*converted\b/m.test(decoded.jsx);
40
+ }
34
41
 
35
42
  export const LIVE_DECK_UNSUPPORTED =
36
43
  "conversion of live presentations requires the add-in path in v1 — " +
@@ -174,21 +181,13 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
174
181
  const native = [];
175
182
  let bundle = null;
176
183
 
177
- // Fold in ONE pass: foldPickedSlides unzips and prunes the whole source
178
- // package per call, so folding slide by slide redid that work for the entire
179
- // deck on every native slide. Three kinds of pick share the pass — a slide
180
- // that stays native, a CONVERTED slide whose preserved natives (OLE /
181
- // SmartArt / unsupported chart kinds) need their verbatim source package kept
182
- // for the render-time splice (design D4/D6), and a converted slide carrying
183
- // the deck's own chart as `<Chart data-sm-source-id>`, which the render
184
- // patches against that same package (native-charts 5.4a).
184
+ // Fold every converted slide in one pass. The package supplies preserved
185
+ // natives AND the original envelope (layouts, passive third-party tags,
186
+ // relationships). Tagged converted JSX alone cannot restore either safely.
185
187
  const picks = [];
186
188
  decodedByIndex.forEach((decoded, index) => {
187
- if (decoded) return;
188
- const conv = convertedSlides.get(index + 1);
189
- if (!conv || conv.preserved?.length > 0 || hasSourcedChart(conv.jsx)) {
190
- picks.push(index + 1);
191
- }
189
+ if (!needsConversion(decoded)) return;
190
+ picks.push(index + 1);
192
191
  });
193
192
  const foldByPick = new Map(
194
193
  (picks.length
@@ -229,7 +228,7 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
229
228
  }
230
229
 
231
230
  decodedByIndex.forEach((decoded, index) => {
232
- if (decoded) {
231
+ if (decoded && !needsConversion(decoded)) {
233
232
  const stem = stemFor(decoded.meta.name, index, taken);
234
233
  let jsx = decoded.jsx;
235
234
  for (const asset of decoded.assets) {
@@ -251,11 +250,14 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
251
250
  if (conv) {
252
251
  // Deterministically converted: editable jsx in the converted dialect,
253
252
  // plus the identity/evidence record push threads back to the server.
254
- const stem = stemFor(conv.name ?? `${pad2(index + 1)}-converted`, index, taken);
253
+ const stem = stemFor(decoded?.meta.name ?? conv.name ?? `${pad2(index + 1)}-converted`, index, taken);
255
254
  let jsx = conv.jsx;
256
255
  for (const [from, to] of convAssetRenames) jsx = jsx.split(from).join(to);
257
256
  fs.writeFileSync(path.join(slidesDir, `${stem}.jsx`), jsx, "utf8");
258
- const guid = conv.guid && !seenGuids.has(conv.guid) ? conv.guid : mintSlideGuid();
257
+ const candidateGuid = decoded?.guid ?? conv.guid;
258
+ const owns = !decoded || ownerIndexByGuid.get(decoded.guid)?.index === index;
259
+ const guid = owns && candidateGuid && !seenGuids.has(candidateGuid)
260
+ ? candidateGuid : mintSlideGuid();
259
261
  seenGuids.add(guid);
260
262
  guids[`slides/${stem}.jsx`] = slideGuidEntry(guid, jsx);
261
263
  writeConversionRecord(
@@ -277,6 +279,7 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
277
279
  },
278
280
  fold?.bytes ?? null,
279
281
  );
282
+ bundle ??= decoded?.meta.bundle ?? null;
280
283
  convertedFiles.push(`${stem}.jsx`);
281
284
  return;
282
285
  }
@@ -329,7 +332,10 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
329
332
  }
330
333
 
331
334
  /**
332
- * Deterministically convert a FILE deck's untagged slides through the add-in's
335
+ * Convert foreign slides and re-convert embedded converted slides against the
336
+ * CURRENT imported PPTX. This recovers a fresh baseline/evidence pair without
337
+ * pretending the original conversion was reviewed. Ordinary authored JSX is
338
+ * restored verbatim. Requests go through the add-in's
333
339
  * `POST /api/cli/brands/:brandId/convert-pptx` route. Returns the route result
334
340
  * (`{ slides, assets, skipped, warnings }` + the `brandId` it ran against) to
335
341
  * hand to {@link adoptDeckFromPptx}, or null when conversion is not available
@@ -349,7 +355,7 @@ export async function convertUntaggedSlides({
349
355
  const { buffer, decodedByIndex } = source ?? readSourceDeck(pptxPath);
350
356
  const slides = [];
351
357
  decodedByIndex.forEach((decoded, i) => {
352
- if (!decoded) slides.push(i + 1);
358
+ if (needsConversion(decoded)) slides.push(i + 1);
353
359
  });
354
360
  if (slides.length === 0) return null;
355
361
 
@@ -372,7 +378,7 @@ export async function convertUntaggedSlides({
372
378
  if (brands.length !== 1) {
373
379
  note(
374
380
  brands.length === 0
375
- ? "no corporate design available to convert against — untagged slides stay native"
381
+ ? "no corporate design available to convert against — slides requiring conversion stay native"
376
382
  : "several corporate designs available — pass `--brand <presFP>` to convert the untagged slides",
377
383
  );
378
384
  return null;
@@ -383,7 +389,7 @@ export async function convertUntaggedSlides({
383
389
  // capability probe — do it here, so a whole .pptx is never shipped to a
384
390
  // backend that will just 404 the route.
385
391
  if (backendURL && !(await client.backendSupportsConvertPptx(backendURL))) {
386
- note("this add-in does not convert foreign slides — untagged slides stay native");
392
+ note("this add-in does not convert foreign slides — slides requiring conversion stay native");
387
393
  return null;
388
394
  }
389
395
  }
@@ -396,15 +402,15 @@ export async function convertUntaggedSlides({
396
402
  backendURL,
397
403
  });
398
404
  if (result?.ok === false) {
399
- note("the add-in declined to convert this file — untagged slides stay native");
405
+ note("the add-in declined to convert this file — slides requiring conversion stay native");
400
406
  return null;
401
407
  }
402
408
  return { ...result, brandId: resolved };
403
409
  } catch (err) {
404
410
  note(
405
411
  err?.code === "convert_route_unavailable"
406
- ? "this add-in does not convert foreign slides — untagged slides stay native"
407
- : `slide conversion unavailable (${err?.message ?? err}) — untagged slides stay native`,
412
+ ? "this add-in does not convert foreign slides — slides requiring conversion stay native"
413
+ : `slide conversion unavailable (${err?.message ?? err}) — slides requiring conversion stay native`,
408
414
  );
409
415
  return null;
410
416
  }