slidemind 0.3.18 → 0.3.20
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/bridge/client/engine-slides.mjs +1 -1
- package/src/builders/build.mjs +23 -23
- package/src/cli/deck.mjs +67 -6
- package/src/cli/help.mjs +5 -0
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slidemind",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.20",
|
|
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.
|
|
3
|
+
"version": "0.3.20",
|
|
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.
|
|
3
|
+
"version": "0.3.20",
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
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 —
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
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
|
-
|
|
647
|
-
|
|
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 &&
|
|
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
|
}
|
package/src/bridge/capture.mjs
CHANGED
|
@@ -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)
|
|
@@ -398,7 +398,7 @@ export async function readSourcePresentation({
|
|
|
398
398
|
resultRoute(searchId, selection, "source-presentation", params),
|
|
399
399
|
{
|
|
400
400
|
method: "GET",
|
|
401
|
-
headers: { accept: "text/markdown, text/plain" },
|
|
401
|
+
headers: { ...(await graphHeaders(b)), accept: "text/markdown, text/plain" },
|
|
402
402
|
timeoutMs: engineTimeoutMs(),
|
|
403
403
|
},
|
|
404
404
|
b,
|
package/src/builders/build.mjs
CHANGED
|
@@ -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
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
//
|
|
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(
|
|
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 === "--
|
|
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
|
-
`
|
|
111
|
-
`
|
|
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
|
-
|
|
461
|
+
// Advisory here: the converted rules print as warnings while slides are
|
|
462
|
+
// still being edited, and block at `review` / `build-pptx`. --strict gates now.
|
|
463
|
+
await buildDeck(deckDir, { strict, enforceConverted: strict });
|
|
403
464
|
if (lintOnly) {
|
|
404
465
|
if (asJson) process.stdout.write(`${JSON.stringify({ deck: path.basename(deckDir), lint: "clean" })}\n`);
|
|
405
466
|
else process.stdout.write("Lint clean (no render — drop --lint-only for the draft previews).\n");
|
package/src/cli/help.mjs
CHANGED
|
@@ -63,6 +63,7 @@ export function printHelp(cmd = null) {
|
|
|
63
63
|
` --out <path> output path (default <deck>/<deck>.pptx)\n` +
|
|
64
64
|
` --force discard manual PowerPoint edits instead of reconciling them\n` +
|
|
65
65
|
` --json machine-readable result\n` +
|
|
66
|
+
` (writes per-slide PNGs of the built deck to <deck>/.build-preview/)\n` +
|
|
66
67
|
` slidemind inspect-template-deck <deck> Inspect a template deck as JSON for pack-owned validation\n` +
|
|
67
68
|
` --pack <dir> explicit template-pack directory (required)\n` +
|
|
68
69
|
` --allow-unrendered-freeform include JSX inserts without .pptx artifacts\n` +
|
|
@@ -76,6 +77,7 @@ export function printHelp(cmd = null) {
|
|
|
76
77
|
` --only <file|N>[,…] render just these slides (a per-slide builder's own check)\n` +
|
|
77
78
|
` --fix apply the deterministic lint autofixes first\n` +
|
|
78
79
|
` --lint-only lint + HTML build, no render (fast fix loop)\n` +
|
|
80
|
+
` --strict converted slides: gate here like review does (they only warn by default)\n` +
|
|
79
81
|
` --no-translate skip the export gate (purely local check)\n` +
|
|
80
82
|
` --translate insist on the export gate (fail instead of skipping when the backend is unreachable)\n` +
|
|
81
83
|
` --json machine-readable result (slides, defects, reviewCanOpen)\n` +
|
|
@@ -144,6 +146,9 @@ export function printHelp(cmd = null) {
|
|
|
144
146
|
` --out <name> file name under assets/ (default imagen-N.png)\n` +
|
|
145
147
|
` --size <s> auto|1024x1024|1536x1024|1024x1536 (default auto)\n` +
|
|
146
148
|
` --quality <q> low|medium|high|auto\n` +
|
|
149
|
+
` --into <slide> place it in this slide file instead of only writing the asset\n` +
|
|
150
|
+
` --behind <id> ... as a background div right before data-sm-source-id={id} (takes its rect)\n` +
|
|
151
|
+
` --rect <l,t,w,h> ... at this rect in px instead\n` +
|
|
147
152
|
` slidemind download-bundle [deck] Materialize or refresh a corporate design's bundle locally\n` +
|
|
148
153
|
` (pane-free for a bound deck, --brand or --all — no PowerPoint needed)\n` +
|
|
149
154
|
` --to <presId> target a specific window (forces the pane route)\n` +
|
package/src/lib/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
|
+
}
|
|
@@ -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
|
+
}
|