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.
@@ -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
+ }