slidemind 0.3.18 → 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.18",
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.18",
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.18",
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
- `export default 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` +
@@ -281,3 +281,111 @@ export function conversionsMissingPreserveSource(conversions, deckDir = null) {
281
281
  .filter(([file, c]) => needsPackage(file, c) && !c.preserveSource)
282
282
  .map(([file]) => file);
283
283
  }
284
+
285
+ /**
286
+ * Preview stand-ins for the ConvertedHost layouts a deck's converted slides
287
+ * invoke: `{ "<HostName>.jsx": "<source>" }`, the shape both preview paths want
288
+ * (the HTML build inlines them as jsx-templates, the local capture engine takes
289
+ * them as bundle layouts). The host's name carries its canvas
290
+ * (`ConvertedHost<cx>x<cy>`, EMU), so the stub sizes itself the way the minted
291
+ * host does instead of assuming 16:9 — a 4:3 source previews at 960x720.
292
+ * Real geometry still comes from the server render.
293
+ *
294
+ * The `// PLACEHOLDERS:` line is not decoration: the capture engine builds its
295
+ * layout registry from exactly that contract and refuses any component without
296
+ * one ("unsupported layout contract: … (available: …)"). Two fields carry
297
+ * weight —
298
+ * • `nativeLayoutName` must be a non-empty string or the entry is dropped
299
+ * from the registry entirely. It names the HOST ITSELF here: a converted
300
+ * slide never rides a brand layout (it renders server-side from its
301
+ * conversion record, and viewDraft keeps it out of the translate gate), so
302
+ * naming a real one would be a lie the CLI cannot check;
303
+ * • `content` must be non-null or the engine treats the slide as a
304
+ * purpose-built layout with no freeform region and captures NO image —
305
+ * which is exactly the "no freeform slides to capture" dead end. Its
306
+ * selector defaults to `.slide-content__area`, so the stub carries that
307
+ * class, and its areas (in points, EMU/12700) are the whole canvas,
308
+ * because on a converted slide every shape is content.
309
+ */
310
+ const HOST_NAME_RE = /<(ConvertedHost(\d+)x(\d+))/;
311
+ const emuToCssPx = (emu) => `${Math.round((Number(emu) / 9525) * 1000) / 1000}px`;
312
+ const emuToPt = (emu) => Math.round((Number(emu) / 12700) * 1000) / 1000;
313
+
314
+ export function convertedHostLayouts(deckDir, conversions) {
315
+ const out = {};
316
+ for (const file of Object.keys(conversions).sort()) {
317
+ let src;
318
+ try {
319
+ src = fs.readFileSync(path.join(deckDir, "slides", file), "utf8");
320
+ } catch {
321
+ continue;
322
+ }
323
+ const m = HOST_NAME_RE.exec(src);
324
+ if (!m || out[`${m[1]}.jsx`]) continue;
325
+ const contract = {
326
+ nativeLayoutName: m[1],
327
+ chrome: [],
328
+ content: {
329
+ type: "obj",
330
+ idx: null,
331
+ areas: [{ left: 0, top: 0, right: emuToPt(m[2]), bottom: emuToPt(m[3]) }],
332
+ },
333
+ };
334
+ out[`${m[1]}.jsx`] =
335
+ `// Preview stand-in for a converted slide's host — the server mints the real one.\n` +
336
+ `// PLACEHOLDERS: ${JSON.stringify(contract)}\n\n` +
337
+ `export default function ${m[1]}({ background, children }) { return <div className="slide-content__area" style={{ position: "relative", width: "${emuToCssPx(m[2])}", height: "${emuToCssPx(m[3])}", overflow: "hidden", background: background || "#FFFFFF" }}>{children}</div>; }\n`;
338
+ }
339
+ return out;
340
+ }
341
+
342
+ /**
343
+ * Every way a deck's conversion records can lie about their source, as
344
+ * user-facing lines. Two hand-renamed slides shipped on the WRONG master
345
+ * layout because their record pointed at a package file that no longer
346
+ * existed: loadConversions simply sent no package, the server fell back to a
347
+ * default layout, and the mismatch only surfaced as a post-build warning.
348
+ *
349
+ * Read from disk (not from the loaded entries) so the wire body stays exactly
350
+ * what the render route expects — these are checks, not payload.
351
+ *
352
+ * Covers: a `source.package` that is set but missing; a `record.slide` that no
353
+ * longer names its own file (a hand rename); and preserved natives / a sourced
354
+ * chart with no package at all.
355
+ */
356
+ export function conversionIntegrityErrors(deckDir, conversions) {
357
+ const dir = conversionsDir(deckDir);
358
+ const errors = [];
359
+ for (const file of Object.keys(conversions).sort()) {
360
+ const stem = stemOf(file);
361
+ let record;
362
+ try {
363
+ record = JSON.parse(fs.readFileSync(path.join(dir, `${stem}.json`), "utf8"));
364
+ } catch {
365
+ continue; // unreadable record — loadConversions already skipped it
366
+ }
367
+ const packageName = record.source?.package
368
+ ? path.basename(record.source.package)
369
+ : null;
370
+ if (packageName && !fs.existsSync(path.join(dir, packageName))) {
371
+ errors.push(
372
+ `${file}: its conversion record points at ${CONVERSIONS_DIR}/${packageName}, which does not ` +
373
+ `exist — the slide would render on a fallback layout. Re-run 'slidemind adopt' for this deck.`,
374
+ );
375
+ }
376
+ if (record.slide && record.slide !== `slides/${stem}.jsx`) {
377
+ errors.push(
378
+ `${file}: its conversion record still claims "slide": "${record.slide}" — the file was renamed ` +
379
+ `by hand. Rename it back, or re-run 'slidemind adopt' (a plan-driven reorder belongs to ` +
380
+ `'proposal-plan renumber', which moves the jsx, the brief, the record and the GUID together).`,
381
+ );
382
+ }
383
+ }
384
+ for (const file of conversionsMissingPreserveSource(conversions, deckDir)) {
385
+ errors.push(
386
+ `${file}: it has preserved native objects or a sourced chart but no ${CONVERSIONS_DIR}/*.slide.pptx ` +
387
+ `to read them from — re-run 'slidemind adopt' for this deck.`,
388
+ );
389
+ }
390
+ return errors;
391
+ }
@@ -0,0 +1,115 @@
1
+ // Put a generated picture INTO a slide, not just next to it.
2
+ //
3
+ // `slidemind imagen` wrote assets/imagen-N.png and left the agent to wire it
4
+ // up by hand — which is where it went wrong: the image was dropped onto the
5
+ // wrong element, or on top of the text that was supposed to sit over it. The
6
+ // picture belongs in one specific place: a plain absolute div, at a known
7
+ // rect, immediately BEFORE the element it should paint behind.
8
+
9
+ // Find the open tag that carries `data-sm-source-id={<id>}` — the converted
10
+ // dialect's stable handle on a source shape. Returns { start, tag } or null.
11
+ function findSourceIdTag(source, id) {
12
+ const marker = new RegExp(`\\sdata-sm-source-id=\\{${Number(id)}\\}`);
13
+ const hit = marker.exec(source);
14
+ if (!hit) return null;
15
+ const start = source.lastIndexOf("<", hit.index);
16
+ if (start < 0) return null;
17
+ let depth = 0;
18
+ let quote = null;
19
+ let i = start;
20
+ for (; i < source.length; i++) {
21
+ const c = source[i];
22
+ if (quote) {
23
+ if (c === quote) quote = null;
24
+ continue;
25
+ }
26
+ if (c === '"' || c === "'") quote = c;
27
+ else if (c === "{") depth++;
28
+ else if (c === "}") depth--;
29
+ else if (c === ">" && depth === 0) break;
30
+ }
31
+ return { start, tag: source.slice(start, i + 1) };
32
+ }
33
+
34
+ const styleProp = (tag, prop) =>
35
+ new RegExp(`\\b${prop}:\\s*"([^"]*)"`).exec(tag)?.[1] ?? null;
36
+
37
+ function imageDiv({ url, left, top, width, height }) {
38
+ return (
39
+ `<div style={{ position: "absolute", left: "${left}", top: "${top}", ` +
40
+ `width: "${width}", height: "${height}", backgroundImage: "url(${url})", ` +
41
+ `backgroundSize: "cover", backgroundPosition: "center" }} />`
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Insert a background-image div into a slide's JSX.
47
+ *
48
+ * `behind`: the data-sm-source-id of the element to paint under — the div
49
+ * takes that element's own rect and is inserted immediately before it, so an
50
+ * overlay keeps sitting on top. `rect` ({left, top, width, height} in px)
51
+ * overrides that geometry, and on its own appends the div at the end of the
52
+ * article.
53
+ *
54
+ * Throws rather than guessing: a picture in the wrong element is exactly the
55
+ * defect this replaces. Never writes inside a preserved native object — that
56
+ * subtree is spliced back verbatim from the source package on every render, so
57
+ * anything added there is silently dropped.
58
+ */
59
+ export function insertImageDiv(source, { url, behind = null, rect = null }) {
60
+ if (!url) throw new Error("insertImageDiv: url is required");
61
+ if (behind == null && !rect) {
62
+ throw new Error("insertImageDiv: pass behind (a data-sm-source-id) or rect");
63
+ }
64
+ const px = (v) => (/px$/.test(String(v)) ? String(v) : `${Number(v)}px`);
65
+
66
+ if (behind != null) {
67
+ const target = findSourceIdTag(source, behind);
68
+ if (!target) {
69
+ throw new Error(`no element with data-sm-source-id={${behind}} in this slide`);
70
+ }
71
+ if (/data-sm-native-object|sm-native-object-/.test(target.tag)) {
72
+ throw new Error(
73
+ `data-sm-source-id={${behind}} is a preserved native object — it is spliced back ` +
74
+ `from the source package on every render, so anything inserted there is lost. ` +
75
+ `Pick a different source id, or place the picture with --rect.`,
76
+ );
77
+ }
78
+ const geom = rect ?? {
79
+ left: styleProp(target.tag, "left"),
80
+ top: styleProp(target.tag, "top"),
81
+ width: styleProp(target.tag, "width"),
82
+ height: styleProp(target.tag, "height"),
83
+ };
84
+ if (!geom.left || !geom.top || !geom.width || !geom.height) {
85
+ throw new Error(
86
+ `the element with data-sm-source-id={${behind}} has no absolute rect to copy — pass --rect`,
87
+ );
88
+ }
89
+ const lineStart = source.lastIndexOf("\n", target.start) + 1;
90
+ const indent = source.slice(lineStart, target.start);
91
+ const div = imageDiv({
92
+ url,
93
+ left: px(geom.left),
94
+ top: px(geom.top),
95
+ width: px(geom.width),
96
+ height: px(geom.height),
97
+ });
98
+ return source.slice(0, lineStart) + `${indent}${div}\n` + source.slice(lineStart);
99
+ }
100
+
101
+ const close = source.lastIndexOf("</article>");
102
+ if (close < 0) {
103
+ throw new Error("no </article> in this slide — pass --behind <source-id> instead");
104
+ }
105
+ const lineStart = source.lastIndexOf("\n", close) + 1;
106
+ const indent = `${source.slice(lineStart, close)}\t`;
107
+ const div = imageDiv({
108
+ url,
109
+ left: px(rect.left),
110
+ top: px(rect.top),
111
+ width: px(rect.width),
112
+ height: px(rect.height),
113
+ });
114
+ return source.slice(0, lineStart) + `${indent}${div}\n` + source.slice(lineStart);
115
+ }
@@ -0,0 +1,433 @@
1
+ // Lint for the CONVERTED dialect (`slidemind adopt`) — the slides the bundle's
2
+ // lint engine cannot see.
3
+ //
4
+ // A converted slide invokes a server-minted ConvertedHost layout, so the
5
+ // add-in's capability-table lint rejects it as an unregistered layout and
6
+ // src/builders/build.mjs skips it. That left the authored half of an adopted
7
+ // deck completely unchecked, and a real proposal shipped with the master's
8
+ // prompt text still in it ("[Action title …]", "xxx", "adjust footer"), a
9
+ // sentence written into the slide-number field frame, and 12pt bold body copy
10
+ // spilling out of fixed `noAutofit` boxes.
11
+ //
12
+ // These four rules are the ones that would have caught that, and they are
13
+ // structural — they read the dialect's own attributes (data-sm-text-frame,
14
+ // data-sm-para, data-sm-rpr, data-sm-field), not the brand.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+
19
+ import { estimateLines } from "./text-fit.mjs";
20
+
21
+ const PT_TO_PX = 96 / 72;
22
+
23
+ // Master prompts that must never survive into a delivered slide. Bracketed
24
+ // text ("[Commercial summary …]", "[First Name]") is caught by shape; these
25
+ // are the bare ones that are not.
26
+ const PROMPT_LITERALS = new Set([
27
+ "xxx",
28
+ "adjust footer",
29
+ "illustration",
30
+ "3 wp template",
31
+ ]);
32
+ const BRACKETED = /^\s*\[.+\]\s*$/s;
33
+ const isPromptText = (text) => {
34
+ const trimmed = String(text ?? "").trim();
35
+ return Boolean(trimmed) && (BRACKETED.test(trimmed) || PROMPT_LITERALS.has(trimmed.toLowerCase()));
36
+ };
37
+
38
+ // A field frame holds a slide number or a date — a handful of characters. Any
39
+ // more is an agent that mistook it for a text box.
40
+ const FIELD_TEXT_MAX = 40;
41
+
42
+ // Placeholder types worth reporting empty. An unfilled title or body is a
43
+ // slide that lost its message; every other type (sldNum, ftr, dt, pic, …) is
44
+ // chrome the master owns.
45
+ const CONTENT_PLACEHOLDERS = new Set(["title", "ctrTitle", "body", "subTitle"]);
46
+
47
+ // Older conversions carry no data-sm-ph, so the frame's GEOMETRY has to stand
48
+ // in for it: the action-title band across the top of the content width, or the
49
+ // thin kicker line above it. Without this the rule fired on every empty text
50
+ // box on a library reference slide — 25 of them on one deck, none actionable.
51
+ const CANVAS_DEFAULT_W = 1280;
52
+ function looksLikeTitleFrame(node, canvasWidth) {
53
+ const top = px(styleProp(node, "top"));
54
+ const width = px(styleProp(node, "width"));
55
+ const height = px(styleProp(node, "height"));
56
+ if (top > 60 || width < canvasWidth * 0.4) return false;
57
+ return height >= 60 || (height >= 10 && height <= 30);
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // A very small JSX reader. The converted dialect is machine-generated and
62
+ // well-formed (every element self-closes or has a matching close tag), so a
63
+ // tag scanner that understands quoted attribute values and `{…}` expressions
64
+ // is enough — no parser dependency.
65
+ // ponytail: a `<` inside slide text (`{"a <b"}`) would read as a tag; add a
66
+ // real parser if that ever shows up.
67
+
68
+ function* scanTags(src) {
69
+ for (let i = 0; i < src.length; i++) {
70
+ if (src[i] !== "<") continue;
71
+ const head = /^<(\/?)([A-Za-z][\w.:-]*)/.exec(src.slice(i, i + 64));
72
+ if (!head) continue;
73
+ let j = i + head[0].length;
74
+ let depth = 0;
75
+ let quote = null;
76
+ for (; j < src.length; j++) {
77
+ const c = src[j];
78
+ if (quote) {
79
+ if (c === quote) quote = null;
80
+ continue;
81
+ }
82
+ if (c === '"' || c === "'") quote = c;
83
+ else if (c === "{") depth++;
84
+ else if (c === "}") depth--;
85
+ else if (c === ">" && depth === 0) break;
86
+ }
87
+ const raw = src.slice(i, j + 1);
88
+ yield {
89
+ close: head[1] === "/",
90
+ name: head[2],
91
+ raw,
92
+ selfClose: raw.endsWith("/>"),
93
+ start: i,
94
+ end: j + 1,
95
+ };
96
+ i = j;
97
+ }
98
+ }
99
+
100
+ // Element tree. Each node keeps its open tag (`raw`) and its inner source.
101
+ function parseJsx(src) {
102
+ const root = { name: "#root", raw: "", contentStart: 0, children: [] };
103
+ const stack = [root];
104
+ for (const t of scanTags(src)) {
105
+ if (t.close) {
106
+ if (stack.length > 1) {
107
+ const node = stack.pop();
108
+ node.inner = src.slice(node.contentStart, t.start);
109
+ }
110
+ continue;
111
+ }
112
+ const node = {
113
+ name: t.name,
114
+ raw: t.raw,
115
+ start: t.start,
116
+ contentStart: t.end,
117
+ inner: "",
118
+ children: [],
119
+ };
120
+ stack[stack.length - 1].children.push(node);
121
+ if (!t.selfClose) stack.push(node);
122
+ }
123
+ while (stack.length > 1) {
124
+ const node = stack.pop();
125
+ node.inner = src.slice(node.contentStart);
126
+ }
127
+ return root;
128
+ }
129
+
130
+ function* walk(node) {
131
+ for (const child of node.children) {
132
+ yield child;
133
+ yield* walk(child);
134
+ }
135
+ }
136
+
137
+ const unescapeAttr = (s) =>
138
+ s
139
+ .replace(/&quot;/g, '"')
140
+ .replace(/&#39;/g, "'")
141
+ .replace(/&lt;/g, "<")
142
+ .replace(/&gt;/g, ">")
143
+ .replace(/&amp;/g, "&");
144
+
145
+ function attr(node, name) {
146
+ const m = new RegExp(`\\s${name}="([^"]*)"`).exec(node.raw);
147
+ return m ? unescapeAttr(m[1]) : null;
148
+ }
149
+
150
+ function jsonAttr(node, name) {
151
+ const raw = attr(node, name);
152
+ if (!raw) return null;
153
+ try {
154
+ return JSON.parse(raw);
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ // `style={{ width: "279.685px", padding: "3px 4px", fontSize: "14pt" }}`
161
+ function styleProp(node, prop) {
162
+ const m = new RegExp(`\\b${prop}:\\s*"([^"]*)"`).exec(node.raw);
163
+ return m ? m[1] : null;
164
+ }
165
+
166
+ const px = (value) => {
167
+ const n = Number.parseFloat(String(value ?? ""));
168
+ return Number.isFinite(n) ? n : 0;
169
+ };
170
+
171
+ // CSS shorthand, 1–4 values → { top, right, bottom, left }.
172
+ function padding(node) {
173
+ const parts = (styleProp(node, "padding") ?? "0")
174
+ .trim()
175
+ .split(/\s+/)
176
+ .map(px);
177
+ const [t, r = t, b = t, l = r] = parts;
178
+ return { top: t, right: r, bottom: b, left: l };
179
+ }
180
+
181
+ // Slide text lives in JSX string expressions: `{"Strategy & "}`.
182
+ const TEXT_RE = /\{\s*"((?:[^"\\]|\\.)*)"\s*\}/g;
183
+
184
+ function textOf(source) {
185
+ let out = "";
186
+ for (const m of String(source ?? "").matchAll(TEXT_RE)) {
187
+ try {
188
+ out += JSON.parse(`"${m[1]}"`);
189
+ } catch {
190
+ out += m[1];
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ const lineAt = (src, offset) => src.slice(0, offset).split("\n").length;
197
+
198
+ // ---------------------------------------------------------------------------
199
+
200
+ /**
201
+ * Lint ONE converted slide. Returns `[{ rule, severity, line, message }]`.
202
+ * `strict` promotes warnings to errors.
203
+ */
204
+ export function lintConvertedSource(fileName, source, { strict = false } = {}) {
205
+ const findings = [];
206
+ const add = (severity, rule, offset, message, meta = null) =>
207
+ findings.push({
208
+ rule,
209
+ severity: severity === "warn" && strict ? "error" : severity,
210
+ line: lineAt(source, offset),
211
+ message,
212
+ ...(meta ? { meta } : {}),
213
+ });
214
+
215
+ // (a) A master prompt that survived the authoring pass. Checked over every
216
+ // text expression in the file, wherever it sits.
217
+ for (const m of source.matchAll(TEXT_RE)) {
218
+ let text;
219
+ try {
220
+ text = JSON.parse(`"${m[1]}"`);
221
+ } catch {
222
+ text = m[1];
223
+ }
224
+ const trimmed = text.trim();
225
+ if (!trimmed) continue;
226
+ if (isPromptText(trimmed)) {
227
+ add(
228
+ "error",
229
+ "converted:prompt-literal",
230
+ m.index,
231
+ `converted:prompt-literal — ${JSON.stringify(trimmed.slice(0, 60))} is master prompt text, not content. Replace it or delete the shape.`,
232
+ );
233
+ }
234
+ }
235
+
236
+ const root = parseJsx(source);
237
+ const nodes = [...walk(root)];
238
+ // The host's name carries the source canvas (`ConvertedHost<cx>x<cy>`, EMU).
239
+ const canvasWidth =
240
+ Number(/<ConvertedHost(\d+)x\d+/.exec(source)?.[1] ?? 0) / 9525 || CANVAS_DEFAULT_W;
241
+ const frames = nodes.filter((n) => attr(n, "data-sm-text-frame") !== null);
242
+ // Older conversions carry no data-sm-ph at all; then an empty frame is the
243
+ // only signal an unfilled placeholder leaves behind.
244
+ const hasPlaceholderAttrs = nodes.some((n) => attr(n, "data-sm-ph") !== null);
245
+
246
+ for (const frame of frames) {
247
+ const ph = attr(frame, "data-sm-ph");
248
+ const sourceId = /data-sm-source-id=\{(\d+)\}/.exec(frame.raw)?.[1] ?? null;
249
+ const text = textOf(frame.inner).trim();
250
+ const paras = [...walk(frame)].filter((n) => attr(n, "data-sm-para") !== null);
251
+
252
+ // (b) An empty title/body placeholder — a slide that lost its headline.
253
+ const isContentPlaceholder = ph
254
+ ? CONTENT_PLACEHOLDERS.has(ph.split(":")[0])
255
+ : !hasPlaceholderAttrs &&
256
+ /<br data-sm-pad=""\s*\/>/.test(frame.inner) &&
257
+ looksLikeTitleFrame(frame, canvasWidth);
258
+ if (isContentPlaceholder && !text) {
259
+ add(
260
+ "warn",
261
+ "converted:empty-placeholder",
262
+ frame.start,
263
+ `converted:empty-placeholder — ${ph ? `the ${ph} frame` : "the action-title frame"}${sourceId ? ` (data-sm-source-id={${sourceId}})` : ""} has no text. Fill it or remove the shape.`,
264
+ );
265
+ }
266
+
267
+ // (c) A field frame (slide number, date, footer) is not a text box.
268
+ for (const para of paras) {
269
+ const runs = [...walk(para)].filter(
270
+ (n) => attr(n, "data-sm-rpr") !== null || attr(n, "data-sm-field") !== null,
271
+ );
272
+ const fieldRuns = runs.filter((n) => attr(n, "data-sm-field") !== null);
273
+ if (fieldRuns.length === 0) continue;
274
+ const field = attr(fieldRuns[0], "data-sm-field");
275
+ const extra = runs
276
+ .filter((n) => attr(n, "data-sm-field") === null)
277
+ .map((n) => textOf(n.inner))
278
+ .join("")
279
+ .trim();
280
+ const fieldText = textOf(para.inner).trim();
281
+ if (extra) {
282
+ add(
283
+ "error",
284
+ "converted:field-frame-text",
285
+ para.start,
286
+ `converted:field-frame-text — ${JSON.stringify(extra.slice(0, 60))} was written into the ${field} field frame. Field frames render the ${field}; put the copy in a real text box.`,
287
+ );
288
+ } else if (fieldText.length > FIELD_TEXT_MAX) {
289
+ add(
290
+ "error",
291
+ "converted:field-frame-text",
292
+ para.start,
293
+ `converted:field-frame-text — the ${field} field frame holds ${fieldText.length} characters (max ${FIELD_TEXT_MAX}). Field frames render the ${field}, not copy.`,
294
+ );
295
+ }
296
+ }
297
+
298
+ // (d) Text fit. `normAutofit` shrinks the copy to the shape, so only it is
299
+ // exempt: `noAutofit` spills, and `spAutoFit` only re-grows the shape when
300
+ // PowerPoint re-lays the text out, which an edit to the jsx never triggers.
301
+ // A frame still holding master prompt text is judged by rule (a), not here —
302
+ // the fit of a prompt says nothing about the fit of the copy replacing it.
303
+ const meta = jsonAttr(frame, "data-sm-text-frame");
304
+ if (!meta || meta.autofit === "normAutofit" || !text) continue;
305
+ if ([...frame.inner.matchAll(TEXT_RE)].some((m) => isPromptText(textOf(m[0])))) continue;
306
+ const pad = padding(frame);
307
+ const innerWidth = px(styleProp(frame, "width")) - pad.left - pad.right;
308
+ // Height is the SHAPE's, not the inset box's: PowerPoint lets text bleed
309
+ // into the vertical insets, and a master's thin label frames (a 13px box
310
+ // with 10px of padding) are drawn that way on purpose.
311
+ const frameHeight = px(styleProp(frame, "height"));
312
+ if (!(innerWidth > 0) || !(frameHeight > 0)) continue;
313
+ // `wrap: "none"` shapes never break a line — the text runs on past the
314
+ // shape edge, which is a width problem this rule does not judge.
315
+ const wraps = meta.wrap !== "none";
316
+
317
+ // A trailing empty paragraph (PowerPoint's endParaRPr carrier) takes a line
318
+ // but pushes nothing out of view — it is not an overflow.
319
+ const fitParas = [...paras];
320
+ while (fitParas.length > 0 && !textOf(fitParas[fitParas.length - 1].inner).trim()) {
321
+ fitParas.pop();
322
+ }
323
+ let used = 0;
324
+ let lines = 0;
325
+ let authored = 0;
326
+ let firstLeading = 0;
327
+ fitParas.forEach((para, i) => {
328
+ const runs = [...walk(para)].filter((n) => attr(n, "data-sm-rpr") !== null);
329
+ const rprs = runs.map((n) => jsonAttr(n, "data-sm-rpr") ?? {});
330
+ const pt =
331
+ Math.max(0, ...rprs.map((r) => px(r.sz) / 100)) ||
332
+ px(styleProp(para, "fontSize")) ||
333
+ 18;
334
+ const bold = rprs.some((r) => r.b === true);
335
+ const paraMeta = jsonAttr(para, "data-sm-para") ?? {};
336
+ const fontPx = pt * PT_TO_PX;
337
+ const spacing = (spec) => {
338
+ if (spec?.kind === "pts") return px(spec.pts) * PT_TO_PX;
339
+ if (spec?.kind === "pct") return (px(spec.pct) / 100) * fontPx;
340
+ return 0;
341
+ };
342
+ const lineHeight =
343
+ paraMeta.lineSpacing?.kind === "pts"
344
+ ? px(paraMeta.lineSpacing.pts) * PT_TO_PX
345
+ : fontPx *
346
+ 1.2 *
347
+ (paraMeta.lineSpacing?.kind === "pct" ? px(paraMeta.lineSpacing.pct) / 100 : 1);
348
+ // An explicit <br /> inside the paragraph starts a new line before wrap.
349
+ let paraLines = 0;
350
+ const segments = para.inner.split(/<br\b[^>]*\/>/);
351
+ for (const segment of segments) {
352
+ paraLines += wraps ? estimateLines(textOf(segment), pt, innerWidth, { bold }) : 1;
353
+ }
354
+ lines += paraLines;
355
+ authored += segments.length;
356
+ if (i === 0) firstLeading = Math.max(0, lineHeight - fontPx);
357
+ // PowerPoint hangs the first paragraph's space-before and the last one's
358
+ // space-after outside the visible stack — counting them flagged every
359
+ // tight one-line box in a pristine master.
360
+ used +=
361
+ (i === 0 ? 0 : spacing(paraMeta.spaceBefore)) +
362
+ paraLines * lineHeight +
363
+ (i === fitParas.length - 1 ? 0 : spacing(paraMeta.spaceAfter));
364
+ });
365
+ // Half-leading above the first line and below the last is air, not ink: a
366
+ // master's boxes routinely hug the cap height of a single line.
367
+ used = Math.max(0, used - firstLeading);
368
+ // Only text that WRAPS past the lines the author wrote is an overflow. A
369
+ // short stack in a box that was always too small for it (a master's three
370
+ // one-word bullets in a two-line placeholder) is the source deck's own
371
+ // layout, and PowerPoint has drawn it that way from the start.
372
+ if (used > frameHeight + 2 && lines > authored) {
373
+ add(
374
+ "error",
375
+ "converted:overflow",
376
+ frame.start,
377
+ `converted:overflow — ${lines} line${lines === 1 ? "" : "s"} / ${Math.round(used)}px into ${Math.round(frameHeight)}px` +
378
+ `${sourceId ? ` (data-sm-source-id={${sourceId}})` : ""}. The text wraps past the ${authored} line${authored === 1 ? "" : "s"} it was written as: shorten it or grow the shape.`,
379
+ { lines, authored, used: Math.round(used), height: Math.round(frameHeight) },
380
+ );
381
+ }
382
+ }
383
+
384
+ findings.sort((a, b) => a.line - b.line);
385
+ return findings;
386
+ }
387
+
388
+ /**
389
+ * Lint a deck's converted slides, in the bundle lint's vocabulary
390
+ * (`[lint:error] file:line — …`).
391
+ *
392
+ * `enforce` is the difference between looking and shipping: `view-draft` runs
393
+ * it advisory (every finding prints as a warning, nothing throws) because the
394
+ * main lane previews slides while they are still half-authored, and a raw
395
+ * adopted master must be viewable. `review` and the render path enforce, so a
396
+ * deck still carrying master prompts cannot ship. `--strict` makes view-draft
397
+ * enforce too, and promotes the empty-placeholder warning to an error.
398
+ */
399
+ export function lintConvertedOrThrow({ slideFiles, strict = false, enforce = true }) {
400
+ const lines = [];
401
+ let errors = 0;
402
+ let warnings = 0;
403
+ for (const file of slideFiles) {
404
+ const fileName = path.basename(file);
405
+ let findings;
406
+ try {
407
+ findings = lintConvertedSource(fileName, fs.readFileSync(file, "utf8"), { strict });
408
+ } catch {
409
+ continue; // unreadable slide — the build reports that on its own
410
+ }
411
+ for (const f of findings) {
412
+ if (f.severity === "error") errors++;
413
+ else warnings++;
414
+ const shown = enforce ? f.severity : "warn";
415
+ lines.push(` [lint:${shown}] ${fileName}:${f.line} — ${f.message}`);
416
+ }
417
+ }
418
+ for (const line of lines) process.stderr.write(`${line}\n`);
419
+ if (errors > 0 && !enforce) {
420
+ process.stderr.write(
421
+ ` [lint:note] ${errors} converted-slide finding${errors === 1 ? "" : "s"} above ` +
422
+ `will block 'slidemind review' and 'build-pptx' until fixed\n`,
423
+ );
424
+ }
425
+ if (errors > 0 && enforce) {
426
+ throw new Error(
427
+ `converted slide lint: ${errors} error${errors === 1 ? "" : "s"}` +
428
+ (warnings ? `, ${warnings} warning${warnings === 1 ? "" : "s"}` : "") +
429
+ `\n${lines.join("\n")}`,
430
+ );
431
+ }
432
+ return { ok: errors === 0, errors, warnings, lines };
433
+ }
@@ -0,0 +1,94 @@
1
+ // How much room does this text actually need? — the cheap half of a fit check.
2
+ //
3
+ // Converted slides (`slidemind adopt`) keep PowerPoint's fixed text boxes:
4
+ // `autofit: "noAutofit"` means the box does NOT grow and PowerPoint does NOT
5
+ // shrink the text — an over-long line simply spills out of the shape and ships
6
+ // that way. Nothing in the local toolchain measured it, so 12pt bold overflow
7
+ // reached a delivered deck.
8
+ //
9
+ // Real measurement needs a browser; this is Arial's advance-width table
10
+ // instead (1000 units/em, the AFM metrics Arial shares with Helvetica), which
11
+ // is exact for the master's Arial runs and close enough elsewhere to catch a
12
+ // line that is 30% too long. ponytail: regular-weight table × a flat bold
13
+ // factor; swap in per-face metrics if a non-Arial brand ever adopts.
14
+
15
+ const PT_TO_PX = 96 / 72;
16
+
17
+ // prettier-ignore
18
+ const W = {
19
+ " ": 278, "!": 278, '"': 355, "#": 556, $: 556, "%": 889, "&": 667, "'": 191,
20
+ "(": 333, ")": 333, "*": 389, "+": 584, ",": 278, "-": 333, ".": 278, "/": 278,
21
+ 0: 556, 1: 556, 2: 556, 3: 556, 4: 556, 5: 556, 6: 556, 7: 556, 8: 556, 9: 556,
22
+ ":": 278, ";": 278, "<": 584, "=": 584, ">": 584, "?": 556, "@": 1015,
23
+ A: 667, B: 667, C: 722, D: 722, E: 667, F: 611, G: 778, H: 722, I: 278, J: 500,
24
+ K: 667, L: 556, M: 833, N: 722, O: 778, P: 667, Q: 778, R: 722, S: 667, T: 611,
25
+ U: 722, V: 667, W: 944, X: 667, Y: 667, Z: 611,
26
+ "[": 278, "\\": 278, "]": 278, "^": 469, _: 556, "`": 333,
27
+ a: 556, b: 556, c: 500, d: 556, e: 556, f: 278, g: 556, h: 556, i: 222, j: 222,
28
+ k: 500, l: 222, m: 833, n: 556, o: 556, p: 556, q: 556, r: 333, s: 500, t: 278,
29
+ u: 556, v: 500, w: 722, x: 500, y: 500, z: 500,
30
+ "{": 334, "|": 260, "}": 334, "~": 584,
31
+ // Latin-1 / typographic characters the master's German and English copy uses.
32
+ "\u00A0": 278, "«": 556, "»": 556, "°": 400, "·": 278,
33
+ "×": 584, "÷": 584, "©": 737, "®": 737, "–": 556,
34
+ "—": 1000, "‘": 222, "’": 222, "‚": 222, "“": 333,
35
+ "”": 333, "„": 333, "…": 1000, "€": 556, "ß": 556,
36
+ "£": 556, "§": 556, "•": 350, "−": 584, "\u00AD": 333,
37
+ };
38
+
39
+ // Accented letters carry their base letter's advance in Arial (the diacritic
40
+ // sits above the glyph box), so strip the combining marks and reuse the table.
41
+ function widthOf(ch) {
42
+ const w = W[ch];
43
+ if (w !== undefined) return w;
44
+ const base = ch.normalize("NFD")[0];
45
+ return W[base] ?? 556;
46
+ }
47
+
48
+ /** Rendered width of `text` in px at `pt`, Arial metrics. */
49
+ export function textWidth(text, pt, { bold = false } = {}) {
50
+ let units = 0;
51
+ for (const ch of String(text ?? "")) units += widthOf(ch);
52
+ return (units / 1000) * pt * PT_TO_PX * (bold ? 1.07 : 1);
53
+ }
54
+
55
+ /**
56
+ * How many lines `text` takes in a box `widthPx` wide — greedy word wrap, the
57
+ * way PowerPoint breaks (on spaces, and inside a word that cannot fit alone).
58
+ * Always at least 1.
59
+ */
60
+ export function estimateLines(text, pt, widthPx, { bold = false } = {}) {
61
+ const str = String(text ?? "");
62
+ if (!str.trim()) return 1;
63
+ if (!(widthPx > 0)) return 1;
64
+ const opts = { bold };
65
+ let lines = 1;
66
+ let used = 0;
67
+ for (const word of str.split(/(\s+)/)) {
68
+ if (!word) continue;
69
+ const w = textWidth(word, pt, opts);
70
+ if (/^\s+$/.test(word)) {
71
+ // Trailing whitespace never pushes a wrap on its own.
72
+ used += w;
73
+ continue;
74
+ }
75
+ if (used > 0 && used + w > widthPx) {
76
+ lines++;
77
+ used = 0;
78
+ }
79
+ if (w > widthPx) {
80
+ // A single word wider than the box breaks mid-word.
81
+ for (const ch of word) {
82
+ const cw = textWidth(ch, pt, opts);
83
+ if (used > 0 && used + cw > widthPx) {
84
+ lines++;
85
+ used = 0;
86
+ }
87
+ used += cw;
88
+ }
89
+ continue;
90
+ }
91
+ used += w;
92
+ }
93
+ return lines;
94
+ }