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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/bridge/build-and-push.mjs +91 -37
- package/src/bridge/capture.mjs +11 -0
- package/src/builders/build.mjs +23 -23
- package/src/cli/deck.mjs +67 -6
- package/src/cli/help.mjs +5 -0
- package/src/lib/adopt.mjs +30 -24
- package/src/lib/conversions.mjs +108 -0
- package/src/lib/jsx-insert.mjs +115 -0
- package/src/lib/lint-converted.mjs +433 -0
- package/src/lib/text-fit.mjs +94 -0
- package/src/pptx/app-props.mjs +0 -4
- package/src/pptx/slide-swap.mjs +0 -3
- package/src/pptx/image-extensions.mjs +0 -190
package/src/lib/conversions.mjs
CHANGED
|
@@ -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(/"/g, '"')
|
|
140
|
+
.replace(/'/g, "'")
|
|
141
|
+
.replace(/</g, "<")
|
|
142
|
+
.replace(/>/g, ">")
|
|
143
|
+
.replace(/&/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
|
+
}
|