@litfamily/litgrok 1.0.13 → 1.0.15
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/.grok/hooks/auto-handoff-gate.mjs +15 -2
- package/.grok/rules/00-litgrok.md +1 -1
- package/.grok/skills/frontend-ui-ux/THIRD-PARTY-NOTICE.txt +0 -5
- package/.grok/skills/frontend-ui-ux/references/slop-register.md +4 -4
- package/.grok/skills/frontend-ui-ux/scripts/import-design-intelligence.mjs +1 -1
- package/.grok/skills/lit-docx/SKILL.md +59 -11
- package/.grok/skills/lit-docx/examples/01-report-ko.md +137 -0
- package/.grok/skills/lit-docx/examples/02-brief-ko.md +82 -0
- package/.grok/skills/lit-docx/examples/03-manual-ko.md +118 -0
- package/.grok/skills/lit-docx/examples/04-proposal-ko.md +92 -0
- package/.grok/skills/lit-docx/examples/05-memo-en.md +42 -0
- package/.grok/skills/lit-docx/examples/06-journal-en.md +90 -0
- package/.grok/skills/lit-docx/examples/07-report-en.md +103 -0
- package/.grok/skills/lit-docx/examples/08-proposal-en.md +90 -0
- package/.grok/skills/lit-docx/examples/09-memo-ko.md +41 -0
- package/.grok/skills/lit-docx/references/components.md +151 -0
- package/.grok/skills/lit-docx/references/direction-step.md +129 -0
- package/.grok/skills/lit-docx/references/page-composition.md +129 -0
- package/.grok/skills/lit-docx/references/tonalities/brief.md +98 -0
- package/.grok/skills/lit-docx/references/tonalities/journal.md +100 -0
- package/.grok/skills/lit-docx/references/tonalities/manual.md +107 -0
- package/.grok/skills/lit-docx/references/tonalities/memo.md +95 -0
- package/.grok/skills/lit-docx/references/tonalities/proposal.md +105 -0
- package/.grok/skills/lit-docx/references/tonalities/report.md +114 -0
- package/.grok/skills/lit-docx/scripts/convert_md_to_docx.py +394 -51
- package/.grok/skills/lit-docx/scripts/docx_design.py +2446 -0
- package/.grok/skills/lit-docx/scripts/docx_gate.py +203 -0
- package/.grok/skills/lit-docx/scripts/docx_layout.py +959 -0
- package/.grok/skills/lit-docx/scripts/run.mjs +3 -2
- package/.grok/skills/lit-docx/scripts/slop_lint.py +63 -17
- package/.grok/skills/lit-docx/templates/tonalities/brief.yaml +29 -0
- package/.grok/skills/lit-docx/templates/tonalities/journal.yaml +26 -0
- package/.grok/skills/lit-docx/templates/tonalities/manual.yaml +27 -0
- package/.grok/skills/lit-docx/templates/tonalities/memo.yaml +27 -0
- package/.grok/skills/lit-docx/templates/tonalities/proposal.yaml +27 -0
- package/.grok/skills/lit-docx/templates/tonalities/report.yaml +27 -0
- package/.grok/skills/lit-pptx/NOTICE.md +1 -1
- package/.grok/skills/lit-pptx/SKILL.md +70 -14
- package/.grok/skills/lit-pptx/agents/analyst.md +1 -1
- package/.grok/skills/lit-pptx/agents/thesis.md +2 -2
- package/.grok/skills/lit-pptx/examples/01-pitch-signal-ko.md +175 -0
- package/.grok/skills/lit-pptx/examples/02-business-review-ledger-ko.md +199 -0
- package/.grok/skills/lit-pptx/examples/03-research-talk-paper-en.md +177 -0
- package/.grok/skills/lit-pptx/examples/04-lecture-chalk-ko.md +176 -0
- package/.grok/skills/lit-pptx/examples/05-data-review-night-en.md +221 -0
- package/.grok/skills/lit-pptx/examples/06-image-led-atlas-en.md +143 -0
- package/.grok/skills/lit-pptx/examples/07-briefing-gazette-ko.md +192 -0
- package/.grok/skills/lit-pptx/examples/08-status-update-ledger-en.md +168 -0
- package/.grok/skills/lit-pptx/examples/09-pitch-studio-en.md +157 -0
- package/.grok/skills/lit-pptx/examples/10-image-led-studio-ko.md +175 -0
- package/.grok/skills/lit-pptx/examples/11-business-review-night-en.md +181 -0
- package/.grok/skills/lit-pptx/examples/12-status-update-gazette-ko.md +199 -0
- package/.grok/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
- package/.grok/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
- package/.grok/skills/lit-pptx/references/density-and-fill.md +155 -0
- package/.grok/skills/lit-pptx/references/direction-step.md +211 -0
- package/.grok/skills/lit-pptx/references/layout-families.md +193 -0
- package/.grok/skills/lit-pptx/references/title-treatments.md +134 -0
- package/.grok/skills/lit-pptx/references/tonalities/atlas.md +124 -0
- package/.grok/skills/lit-pptx/references/tonalities/chalk.md +135 -0
- package/.grok/skills/lit-pptx/references/tonalities/gazette.md +127 -0
- package/.grok/skills/lit-pptx/references/tonalities/ledger.md +134 -0
- package/.grok/skills/lit-pptx/references/tonalities/night.md +132 -0
- package/.grok/skills/lit-pptx/references/tonalities/paper.md +127 -0
- package/.grok/skills/lit-pptx/references/tonalities/signal.md +118 -0
- package/.grok/skills/lit-pptx/references/tonalities/studio.md +123 -0
- package/.grok/skills/lit-pptx/scripts/compile-deck.js +65 -17
- package/.grok/skills/lit-pptx/scripts/craft_extras.py +82 -18
- package/.grok/skills/lit-pptx/scripts/deck_output.py +598 -0
- package/.grok/skills/lit-pptx/scripts/embed_fonts.py +1 -1
- package/.grok/skills/lit-pptx/scripts/inventory.py +6 -2
- package/.grok/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
- package/.grok/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
- package/.grok/skills/lit-pptx/scripts/lib/layout-resolver.js +401 -16
- package/.grok/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
- package/.grok/skills/lit-pptx/scripts/lib/render-adapter-html.js +2 -2
- package/.grok/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -126
- package/.grok/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
- package/.grok/skills/lit-pptx/scripts/lib/slide-ast.js +54 -6
- package/.grok/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
- package/.grok/skills/lit-pptx/scripts/qa_deck.py +43 -10
- package/.grok/skills/lit-pptx/scripts/run.mjs +3 -2
- package/.grok/skills/lit-pptx/scripts/validate_pptx.py +17 -5
- package/.grok/skills/lit-pptx/templates/boilerplate_professional.css +6 -6
- package/.grok/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
- package/.grok/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
- package/.grok/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
- package/.grok/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
- package/.grok/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +99 -0
- package/.grok/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +104 -0
- package/.grok/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +99 -0
- package/.grok/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +100 -0
- package/.grok/skills/lit-pptx/templates/tonalities/night/pack.yaml +100 -0
- package/.grok/skills/lit-pptx/templates/tonalities/paper/pack.yaml +105 -0
- package/.grok/skills/lit-pptx/templates/tonalities/signal/pack.yaml +101 -0
- package/.grok/skills/lit-pptx/templates/tonalities/studio/pack.yaml +99 -0
- package/.grok/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
- package/.grok/skills/lit-typographic-motion/fonts/pins.json +6 -6
- package/.grok/skills/lit-typographic-motion/references/runtime.md +1 -1
- package/.grok/skills/lit-typographic-motion/references/style-bible.md +2 -2
- package/.grok/skills/lit-typographic-motion/references/type-craft.md +2 -2
- package/.grok/skills/lit-typographic-motion/scripts/lib/gate.mjs +1 -1
- package/.grok/skills/litgrok/SKILL.md +1 -1
- package/.grok-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +9 -9
- package/README_ko-KR.md +9 -9
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference.md +1 -1
- package/docs/reference_ko-KR.md +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/.grok/skills/lit-pptx/pretendard-font/public/static/PretendardGOV-Bold.otf +0 -0
- package/.grok/skills/lit-pptx/pretendard-font/public/static/PretendardGOV-Regular.otf +0 -0
|
@@ -139,12 +139,16 @@ function buildBlocks(layout, content) {
|
|
|
139
139
|
continue;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
// Check for standalone image
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
142
|
+
// Check for standalone images: a run of image lines keeps every image, each with its caption.
|
|
143
|
+
const imageLines = remaining.split("\n").filter((l) => l.trim());
|
|
144
|
+
if (imageLines.length && imageLines.every((l) => l.trim().startsWith("![")) && imageLines.some((l) => parseImage(l))) {
|
|
145
|
+
for (const line of imageLines) {
|
|
146
|
+
const img = parseImage(line);
|
|
147
|
+
if (!img) continue;
|
|
148
|
+
blocks.push({ type: "image", src: img.src, caption: img.alt || "" });
|
|
149
|
+
if (img.alt) {
|
|
150
|
+
blocks.push({ type: "figure-caption", caption: img.alt });
|
|
151
|
+
}
|
|
148
152
|
}
|
|
149
153
|
continue;
|
|
150
154
|
}
|
|
@@ -432,11 +436,55 @@ function buildDirectiveBlocks(seg, layout) {
|
|
|
432
436
|
return [block];
|
|
433
437
|
}
|
|
434
438
|
|
|
439
|
+
case "chart":
|
|
440
|
+
return buildChartBlocks(value, attrs, positional);
|
|
441
|
+
|
|
435
442
|
default:
|
|
436
443
|
return [buildDirectiveBlock(directive, value)];
|
|
437
444
|
}
|
|
438
445
|
}
|
|
439
446
|
|
|
447
|
+
const CHART_TYPES = new Set(["bar", "column", "line", "area", "pie", "doughnut", "stacked"]);
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* A native, editable chart. The body is a pipe table: the first column holds the
|
|
451
|
+
* categories, every further column is one series. It travels as a kpi-table block
|
|
452
|
+
* with a `chart` field, so any layout that accepts a table accepts a chart, and a
|
|
453
|
+
* renderer that cannot draw charts still shows the numbers as a table.
|
|
454
|
+
*
|
|
455
|
+
* ::: chart type=column unit="억 원"
|
|
456
|
+
* | 분기 | 매출 | 영업이익 |
|
|
457
|
+
* |---|---|---|
|
|
458
|
+
* | 1Q | 1,120 | 101 |
|
|
459
|
+
* > 분기별 매출과 영업이익 (예시 데이터)
|
|
460
|
+
* :::
|
|
461
|
+
*/
|
|
462
|
+
function buildChartBlocks(value, attrs, positional) {
|
|
463
|
+
const table = parseTable(value);
|
|
464
|
+
if (!table || table.headers.length < 2 || table.rows.length === 0) {
|
|
465
|
+
throw new Error('"::: chart" needs a pipe table: categories in the first column, one column per series');
|
|
466
|
+
}
|
|
467
|
+
const type = String(attrs.type || positional[0] || "column").toLowerCase();
|
|
468
|
+
if (!CHART_TYPES.has(type)) {
|
|
469
|
+
throw new Error(`"::: chart" type must be one of ${[...CHART_TYPES].join(", ")}; got "${type}"`);
|
|
470
|
+
}
|
|
471
|
+
const captionLine = value.split("\n").map((l) => l.trim()).find((l) => l.startsWith("> "));
|
|
472
|
+
const caption = captionLine ? captionLine.replace(/^>\s*/, "") : null;
|
|
473
|
+
const chart = { type };
|
|
474
|
+
if (attrs.unit != null) chart.unit = String(attrs.unit);
|
|
475
|
+
if (attrs.title != null) chart.title = String(attrs.title);
|
|
476
|
+
if (attrs.labels != null) chart.labels = attrs.labels !== false && attrs.labels !== "false";
|
|
477
|
+
const blocks = [{
|
|
478
|
+
type: "kpi-table",
|
|
479
|
+
headers: table.headers,
|
|
480
|
+
rows: table.rows,
|
|
481
|
+
chart,
|
|
482
|
+
...(caption ? { caption } : {}),
|
|
483
|
+
}];
|
|
484
|
+
if (caption) blocks.push({ type: "table-caption", caption, figure: true });
|
|
485
|
+
return blocks;
|
|
486
|
+
}
|
|
487
|
+
|
|
440
488
|
/**
|
|
441
489
|
* Build a directive block from name and content.
|
|
442
490
|
*/
|
|
@@ -12,6 +12,21 @@ const path = require("path");
|
|
|
12
12
|
|
|
13
13
|
const ENROLLED_DIR = path.resolve(__dirname, "../../templates/enrolled");
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Where templates are looked up, first match wins: every directory in
|
|
17
|
+
* LIT_PPTX_TEMPLATE_DIRS (path-delimiter separated), then .lit-pptx/templates
|
|
18
|
+
* under the working directory (learn_template.py writes there by default),
|
|
19
|
+
* then the templates bundled with the skill. The installed skill directory is
|
|
20
|
+
* read-only, so a learned template never has to be written into it.
|
|
21
|
+
*/
|
|
22
|
+
function templateDirs() {
|
|
23
|
+
const extra = (process.env.LIT_PPTX_TEMPLATE_DIRS || "")
|
|
24
|
+
.split(path.delimiter)
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map((dir) => path.resolve(dir));
|
|
27
|
+
return [...extra, path.resolve(process.cwd(), ".lit-pptx", "templates"), ENROLLED_DIR];
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
/**
|
|
16
31
|
* Minimal YAML parser for the project's template files.
|
|
17
32
|
* Handles nested mappings, lists, quoted strings, and numeric values.
|
|
@@ -276,10 +291,27 @@ function parseSequence(lines, startLine, minIndent) {
|
|
|
276
291
|
return { value: result, nextLine: i };
|
|
277
292
|
}
|
|
278
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Drop a trailing YAML comment (whitespace, then `#`) from a scalar. A quoted
|
|
296
|
+
* value ends at its closing quote, so a `#` inside the quotes stays. Without
|
|
297
|
+
* this, `char_spacing: -0.5 # note` parsed as a string and every run of the
|
|
298
|
+
* template was written with spc="NaN".
|
|
299
|
+
*/
|
|
300
|
+
function stripInlineComment(raw) {
|
|
301
|
+
const quote = raw[0];
|
|
302
|
+
if (quote === '"' || quote === "'") {
|
|
303
|
+
const close = raw.indexOf(quote, 1);
|
|
304
|
+
if (close > 0 && /^\s+#/.test(raw.slice(close + 1))) return raw.slice(0, close + 1);
|
|
305
|
+
return raw;
|
|
306
|
+
}
|
|
307
|
+
return raw.replace(/\s+#.*$/, "");
|
|
308
|
+
}
|
|
309
|
+
|
|
279
310
|
/**
|
|
280
311
|
* Parse a scalar YAML value (string, number, boolean, null).
|
|
281
312
|
*/
|
|
282
313
|
function parseScalar(raw) {
|
|
314
|
+
raw = stripInlineComment(raw);
|
|
283
315
|
if (raw === "null" || raw === "~") return null;
|
|
284
316
|
if (raw === "true") return true;
|
|
285
317
|
if (raw === "false") return false;
|
|
@@ -312,23 +344,30 @@ function parseScalar(raw) {
|
|
|
312
344
|
* @returns {string[]}
|
|
313
345
|
*/
|
|
314
346
|
function listTemplates() {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
.
|
|
318
|
-
.
|
|
319
|
-
fs.statSync(path.join(
|
|
320
|
-
|
|
347
|
+
const names = new Set();
|
|
348
|
+
for (const dir of templateDirs()) {
|
|
349
|
+
if (!fs.existsSync(dir)) continue;
|
|
350
|
+
for (const name of fs.readdirSync(dir)) {
|
|
351
|
+
if (fs.statSync(path.join(dir, name)).isDirectory()) names.add(name);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return [...names].sort();
|
|
321
355
|
}
|
|
322
356
|
|
|
323
357
|
/**
|
|
324
358
|
* Load an enrolled template by name.
|
|
325
|
-
* @param {string} name — Template directory name (e.g. "
|
|
359
|
+
* @param {string} name — Template directory name (e.g. "MY-BRAND")
|
|
326
360
|
* @returns {{ name: string, version: string, template: object, mapping: object, capabilities: object }}
|
|
327
361
|
*/
|
|
328
362
|
function loadTemplate(name) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
363
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(String(name))) {
|
|
364
|
+
throw new Error(`Invalid template name: "${name}"`);
|
|
365
|
+
}
|
|
366
|
+
const dir = templateDirs()
|
|
367
|
+
.map((base) => path.join(base, name))
|
|
368
|
+
.find((candidate) => fs.existsSync(path.join(candidate, "template.yaml")));
|
|
369
|
+
if (!dir) {
|
|
370
|
+
throw new Error(`Unknown template: "${name}" not found in ${templateDirs().join(", ")}`);
|
|
332
371
|
}
|
|
333
372
|
|
|
334
373
|
const readFile = (basename) => {
|
|
@@ -349,7 +388,292 @@ function loadTemplate(name) {
|
|
|
349
388
|
template,
|
|
350
389
|
mapping,
|
|
351
390
|
capabilities,
|
|
391
|
+
pack: legacyPack(template.name || name, template),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Tonality packs ──────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
const grid = require("./grid-resolver");
|
|
398
|
+
|
|
399
|
+
const TONALITY_DIR = path.resolve(__dirname, "../../templates/tonalities");
|
|
400
|
+
|
|
401
|
+
/** Pack lookup, first match wins, in the same order as templates. */
|
|
402
|
+
function tonalityDirs() {
|
|
403
|
+
const extra = (process.env.LIT_PPTX_TONALITY_DIRS || "")
|
|
404
|
+
.split(path.delimiter)
|
|
405
|
+
.filter(Boolean)
|
|
406
|
+
.map((dir) => path.resolve(dir));
|
|
407
|
+
return [...extra, path.resolve(process.cwd(), ".lit-pptx", "tonalities"), TONALITY_DIR];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function listTonalities() {
|
|
411
|
+
const names = new Set();
|
|
412
|
+
for (const dir of tonalityDirs()) {
|
|
413
|
+
if (!fs.existsSync(dir)) continue;
|
|
414
|
+
for (const name of fs.readdirSync(dir)) {
|
|
415
|
+
if (fs.existsSync(path.join(dir, name, "pack.yaml"))) names.add(name);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return [...names].sort();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const PALETTE_ROLES = ["ground", "surface", "ink", "ink-muted", "line", "accent", "accent-deep", "accent-tint", "field", "on-field", "positive", "negative"];
|
|
422
|
+
const FACE_KEYS = ["display", "title", "body", "label", "numeral"];
|
|
423
|
+
const FACES = {
|
|
424
|
+
"Pretendard Regular": { font: "Pretendard", bold: false },
|
|
425
|
+
"Pretendard Bold": { font: "Pretendard", bold: true },
|
|
426
|
+
"A2Z Light": { font: "에이투지체 3 Light", bold: false },
|
|
427
|
+
"A2Z Regular": { font: "에이투지체 4 Regular", bold: false },
|
|
428
|
+
"A2Z Medium": { font: "에이투지체 5 Medium", bold: false },
|
|
429
|
+
"A2Z Bold": { font: "에이투지체 7 Bold", bold: false },
|
|
430
|
+
"A2Z Black": { font: "에이투지체 9 Black", bold: false },
|
|
431
|
+
};
|
|
432
|
+
const DECORATIONS = ["accent-rule", "hairline-rule", "header-band", "rail-fill", "colour-field", "caption-band", "column-hairlines", "box-outline", "mark-underline", "numeral"];
|
|
433
|
+
const SLIDE_ROLES = ["content", "data", "data-takeaway", "sequence", "image", "statement", "reference", "definition"];
|
|
434
|
+
const FILL_POLICIES = ["distribute", "step-up", "anchor-visual", "change-family"];
|
|
435
|
+
// How a pack carries its display slides: the device on a type-led cover, around a statement,
|
|
436
|
+
// behind a big number and under a closing's next step (`display.index` adds the part index to
|
|
437
|
+
// section slides).
|
|
438
|
+
const DISPLAY_DEVICES = {
|
|
439
|
+
cover: ["plain", "drench", "plate", "rail", "band", "figures", "rules", "numeral"],
|
|
440
|
+
statement: ["open", "drench", "rules", "plate", "offset"],
|
|
441
|
+
number: ["field", "tint", "outline"],
|
|
442
|
+
closing: ["band", "box", "rules"],
|
|
443
|
+
};
|
|
444
|
+
// Contrast pairs every pack must meet (text pairs 4.5:1, large marks 3:1).
|
|
445
|
+
const CONTRAST_PAIRS = [["ink", "ground", 4.5], ["ink", "surface", 4.5], ["ink-muted", "ground", 4.5], ["ink-muted", "surface", 4.5],
|
|
446
|
+
["accent", "ground", 3.0], ["accent-deep", "accent-tint", 4.5], ["ink", "accent-tint", 4.5], ["on-field", "field", 4.5],
|
|
447
|
+
["positive", "ground", 4.5], ["negative", "ground", 4.5]];
|
|
448
|
+
|
|
449
|
+
function luminance(hexColour) {
|
|
450
|
+
const v = String(hexColour).replace("#", "");
|
|
451
|
+
const [r, g, b] = [0, 2, 4].map((i) => parseInt(v.slice(i, i + 2), 16) / 255)
|
|
452
|
+
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
|
453
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function contrast(a, b) {
|
|
457
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
458
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Check a parsed pack against the pack schema; throws one message naming every problem. */
|
|
462
|
+
function validatePack(pack, file) {
|
|
463
|
+
const problems = [];
|
|
464
|
+
const need = (cond, msg) => { if (!cond) problems.push(msg); };
|
|
465
|
+
const isList = (v) => Array.isArray(v);
|
|
466
|
+
const hex = (v) => typeof v === "string" && /^#[0-9A-Fa-f]{6}$/u.test(v);
|
|
467
|
+
need(typeof pack.id === "string" && /^[a-z][a-z0-9-]{2,23}$/u.test(pack.id), "id must be 3-24 lowercase letters, digits or hyphens");
|
|
468
|
+
need(typeof pack.name === "string" && pack.name.length >= 1 && pack.name.length <= 24, "name must be 1-24 characters");
|
|
469
|
+
need(typeof pack.version === "string" && /^\d+\.\d+\.\d+$/u.test(pack.version), "version must be semver");
|
|
470
|
+
need(typeof pack.intent === "string" && pack.intent.length > 0, "intent is missing");
|
|
471
|
+
need(isList(pack.canvas) && pack.canvas.length > 0 && pack.canvas.every((c) => grid.CANVAS[c]), "canvas must list 16:9 and/or 4:3");
|
|
472
|
+
// `faces` is the deck's default and is Pretendard only; `faces-a2z` applies when the source asks.
|
|
473
|
+
for (const key of ["faces", "faces-pretendard-only", "faces-a2z"]) {
|
|
474
|
+
if (key === "faces-a2z" && pack[key] == null) continue;
|
|
475
|
+
const faces = pack[key] || {};
|
|
476
|
+
for (const role of FACE_KEYS) {
|
|
477
|
+
need(FACES[faces[role]] && (key === "faces-a2z" || /^Pretendard /u.test(faces[role])), `${key}.${role} must name a bundled face${key === "faces-a2z" ? "" : " (Pretendard Regular or Bold)"}`);
|
|
478
|
+
}
|
|
479
|
+
const distinct = new Set(FACE_KEYS.map((r) => faces[r]));
|
|
480
|
+
need(distinct.size <= 3, `${key} uses ${distinct.size} faces; at most 3`);
|
|
481
|
+
need(new Set([...distinct].map((f) => String(f).split(" ")[0])).size <= 2, `${key} mixes more than two families`);
|
|
482
|
+
}
|
|
483
|
+
const palette = pack.palette || {};
|
|
484
|
+
for (const role of PALETTE_ROLES) need(hex(palette[role]), `palette.${role} is missing or not #RRGGBB`);
|
|
485
|
+
need(isList(palette.series) && palette.series.length >= 2 && palette.series.length <= 4 && palette.series.every(hex),
|
|
486
|
+
"palette.series must list 2-4 #RRGGBB colours");
|
|
487
|
+
if (PALETTE_ROLES.every((r) => hex(palette[r]))) {
|
|
488
|
+
for (const [a, b, min] of CONTRAST_PAIRS) {
|
|
489
|
+
const got = contrast(palette[a], palette[b]);
|
|
490
|
+
need(got >= min, `${a} on ${b} is ${got.toFixed(2)}:1, below ${min}:1`);
|
|
491
|
+
}
|
|
492
|
+
need(Math.abs(luminance(palette.line) - luminance(palette.ground)) >= 0.08, "line is not visible on ground (luminance difference below 0.08)");
|
|
493
|
+
if (isList(palette.series) && palette.series.every(hex)) {
|
|
494
|
+
need(palette.series[0].toUpperCase() === palette.accent.toUpperCase(), "palette.series must start with the accent");
|
|
495
|
+
for (const s of palette.series) need(contrast(s, palette.ground) >= 3, `series colour ${s} is below 3:1 on ground`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
need(["presented", "reading"].includes(pack.ramp), "ramp must be presented or reading");
|
|
499
|
+
need(typeof pack.hero === "boolean", "hero must be true or false");
|
|
500
|
+
need(Number.isInteger(pack.density) && pack.density >= 1 && pack.density <= 10, "density must be an integer 1-10");
|
|
501
|
+
need(Number.isInteger(pack.variance) && pack.variance >= 4 && pack.variance <= 10, "variance must be an integer 4-10");
|
|
502
|
+
need(pack.radius === 0 || pack.radius === 6, "radius must be 0 or 6");
|
|
503
|
+
need(["border", "fill", "fill+border"].includes(pack.edge), "edge must be border, fill or fill+border");
|
|
504
|
+
const treatments = isList(pack.treatments) ? pack.treatments : [];
|
|
505
|
+
need(treatments.length >= 3 && treatments.length <= 5, "treatments must list 3-5 title treatments");
|
|
506
|
+
for (const t of treatments) need(grid.TREATMENTS.includes(t), `unknown treatment "${t}" (known: ${grid.TREATMENTS.join(", ")})`);
|
|
507
|
+
const roles = pack["role-defaults"] || {};
|
|
508
|
+
for (const role of SLIDE_ROLES) need(treatments.includes(roles[role]), `role-defaults.${role} must be one of the pack's treatments`);
|
|
509
|
+
// A family's own title in this pack, ahead of its role default: the structure the pack draws it with.
|
|
510
|
+
const structure = pack.structure || {};
|
|
511
|
+
need(typeof structure === "object" && !Array.isArray(structure), "structure must be a map of family to treatment");
|
|
512
|
+
for (const [f, t] of Object.entries(structure)) {
|
|
513
|
+
need(Boolean(grid.FAMILIES[f]), `structure names an unknown family "${f}"`);
|
|
514
|
+
need(treatments.includes(t) && grid.FAMILIES[f] && grid.FAMILIES[f].allow.includes(t), `structure.${f} must be a treatment of the pack that the family allows`);
|
|
515
|
+
}
|
|
516
|
+
for (const d of pack.decoration || []) need(DECORATIONS.includes(d), `unknown decoration "${d}"`);
|
|
517
|
+
need(pack.rail == null || ["surface", "field"].includes(pack.rail), "rail must be surface or field");
|
|
518
|
+
const display = pack.display || {};
|
|
519
|
+
need(typeof display === "object" && !Array.isArray(display), "display must be a map");
|
|
520
|
+
for (const [key, devices] of Object.entries(DISPLAY_DEVICES)) {
|
|
521
|
+
need(display[key] == null || devices.includes(display[key]), `display.${key} must be one of ${devices.join(", ")}`);
|
|
522
|
+
}
|
|
523
|
+
need(display.index == null || typeof display.index === "boolean", "display.index must be true or false");
|
|
524
|
+
for (const key of Object.keys(display)) need(key === "index" || DISPLAY_DEVICES[key], `unknown display key "${key}"`);
|
|
525
|
+
const families = pack["layout-families"] || [];
|
|
526
|
+
need(families.length >= 6, "layout-families must list at least 6 families");
|
|
527
|
+
for (const f of families) {
|
|
528
|
+
need(Boolean(grid.FAMILIES[f]), `unknown layout family "${f}"`);
|
|
529
|
+
if (grid.FAMILIES[f]) need(grid.FAMILIES[f].allow.some((t) => treatments.includes(t)), `the ${f} family allows none of the pack's treatments`);
|
|
530
|
+
}
|
|
531
|
+
for (const [key, kind, min] of [["covers", "cover", 3], ["sections", "section", 2], ["closings", "closing", 2]]) {
|
|
532
|
+
const list = pack[key] || [];
|
|
533
|
+
need(list.length >= min, `${key} must list at least ${min} variants`);
|
|
534
|
+
for (const v of list) need(grid.VARIANTS[kind].includes(v), `unknown ${kind} variant "${v}"`);
|
|
535
|
+
}
|
|
536
|
+
for (const key of ["table", "chart", "image"]) need(pack[key] && typeof pack[key] === "object", `${key} settings are missing`);
|
|
537
|
+
const order = pack["fill-order"] || [];
|
|
538
|
+
need(order.length === FILL_POLICIES.length && FILL_POLICIES.every((p) => order.includes(p)), `fill-order must order ${FILL_POLICIES.join(", ")}`);
|
|
539
|
+
if (problems.length) throw new Error(`Tonality pack ${file}: ${problems.join("; ")}`);
|
|
540
|
+
return pack;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Load and check one tonality pack by id. */
|
|
544
|
+
function loadPack(name) {
|
|
545
|
+
// Pack ids are lowercase; a display name such as "Ledger" names the same pack.
|
|
546
|
+
const id = String(name).toLowerCase();
|
|
547
|
+
const known = listTonalities();
|
|
548
|
+
const dir = tonalityDirs()
|
|
549
|
+
.map((base) => path.join(base, String(id)))
|
|
550
|
+
.find((candidate) => /^[a-z][a-z0-9-]*$/u.test(String(id)) && fs.existsSync(path.join(candidate, "pack.yaml")));
|
|
551
|
+
if (!dir) {
|
|
552
|
+
throw new Error(`Unknown tonality "${name}". Tonalities: ${known.join(", ") || "(none)"}; legacy templates: ${listTemplates().join(", ")}`);
|
|
553
|
+
}
|
|
554
|
+
const file = path.join(dir, "pack.yaml");
|
|
555
|
+
const pack = validatePack(parseYaml(fs.readFileSync(file, "utf-8")), file);
|
|
556
|
+
if (pack.id !== path.basename(dir)) throw new Error(`Tonality pack ${file}: id "${pack.id}" must match its folder name`);
|
|
557
|
+
return pack;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Today's hand-tuned geometry, now a token set. A legacy template draws with these values unless
|
|
561
|
+
// its template.yaml overrides one under `pack: tokens:`; a tonality pack derives its own from the dials.
|
|
562
|
+
const LEGACY_TOKENS = {
|
|
563
|
+
card: { pad: 0.30, chip: 0.52, minH: 1.6, gap: 0.28, radius: 0.16 },
|
|
564
|
+
kpi: { minCardWidth: 1.9, blockHeight: 1.75, gap: 0.26 },
|
|
565
|
+
bullet: { size: 16, x: 0.59, w: 8.8, h: 0.38, ySpacing: 0.42, lineSpacing: 1.25 },
|
|
566
|
+
secHeader: { size: 18, x: 0.39, w: 9.0, h: 0.5 },
|
|
567
|
+
summary: { headerW: 4.3, bulletW: 4.8, bulletSize: 14, bulletH: 0.34, ySpacing: 0.38, lineSpacing: 1.05 },
|
|
568
|
+
toc: { size: 18, x: 0.39, w: 7.5, h: 0.55, yStart: 0.957, ySpacing: 0.65 },
|
|
569
|
+
notice: { x: 0.45, h: 0.3, bottom: 0.14, size: 10.5 },
|
|
570
|
+
};
|
|
571
|
+
// Rich card, KPI and table rendering plus the accent, font and background switches, for a copy of
|
|
572
|
+
// an older template that still says `render_style: azure` instead of listing its capabilities.
|
|
573
|
+
const RICH_LEGACY_CAPABILITIES = ["rich-blocks", "recolor", "font-swap", "mesh-background"];
|
|
574
|
+
|
|
575
|
+
function mergeTokens(base, extra) {
|
|
576
|
+
const out = {};
|
|
577
|
+
for (const [group, values] of Object.entries(base)) out[group] = { ...values, ...((extra && extra[group]) || {}) };
|
|
578
|
+
return out;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* A legacy template read as a pack: one title treatment, its own palette mapped onto the pack
|
|
583
|
+
* roles, its capabilities, and today's geometry as tokens. It renders through the legacy path, so
|
|
584
|
+
* a deck that names the template keeps its look.
|
|
585
|
+
*/
|
|
586
|
+
function legacyPack(name, template) {
|
|
587
|
+
const pal = template.palette || {};
|
|
588
|
+
const own = template.pack || {};
|
|
589
|
+
const capabilities = Array.isArray(own.capabilities)
|
|
590
|
+
? own.capabilities
|
|
591
|
+
: template.render_style === "azure" ? RICH_LEGACY_CAPABILITIES : [];
|
|
592
|
+
const accent = pal.primary || `#${String((template.global_typography || {}).bullet_color || "3C3836").replace("#", "")}`;
|
|
593
|
+
const palette = {
|
|
594
|
+
ground: pal.ground || pal.paper || "#FFFFFF",
|
|
595
|
+
surface: pal.tint || pal.paper_warm || pal.header_fill || "#F6F6F6",
|
|
596
|
+
ink: pal.ink || "#000000",
|
|
597
|
+
"ink-muted": pal.ink_muted || pal.muted || pal.ink || "#555555",
|
|
598
|
+
line: pal.line || "#CCCCCC",
|
|
599
|
+
accent,
|
|
600
|
+
"accent-deep": pal.primary_deep || pal.ink || accent,
|
|
601
|
+
"accent-tint": pal.tint || pal.paper_warm || "#F6F6F6",
|
|
602
|
+
field: pal.primary_deep || pal.ink || accent,
|
|
603
|
+
"on-field": "#FFFFFF",
|
|
604
|
+
};
|
|
605
|
+
const report = CONTRAST_PAIRS
|
|
606
|
+
.filter(([a, b]) => palette[a] && palette[b])
|
|
607
|
+
.map(([a, b, min]) => ({ pair: `${a} on ${b}`, ratio: +contrast(palette[a], palette[b]).toFixed(2), min }))
|
|
608
|
+
.filter((r) => r.ratio < r.min);
|
|
609
|
+
return {
|
|
610
|
+
id: name,
|
|
611
|
+
name,
|
|
612
|
+
legacy: true,
|
|
613
|
+
treatments: ["top-rule"],
|
|
614
|
+
capabilities,
|
|
615
|
+
palette,
|
|
616
|
+
tokens: mergeTokens(LEGACY_TOKENS, own.tokens),
|
|
617
|
+
contrast: report,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function hasCapability(templateObj, capability) {
|
|
622
|
+
return Boolean(templateObj && templateObj.pack && (templateObj.pack.capabilities || []).includes(capability));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Load a tonality pack as the template object the resolver and renderer read. The dials come from
|
|
627
|
+
* the deck (frontmatter or flags) or the pack defaults; the renderer never sees the pack's name.
|
|
628
|
+
*/
|
|
629
|
+
function loadTonality(id, options = {}) {
|
|
630
|
+
const pack = loadPack(id);
|
|
631
|
+
const density = grid.clampDial(options.density != null ? options.density : pack.density, pack.density);
|
|
632
|
+
const variance = grid.clampDial(options.variance != null ? options.variance : pack.variance, pack.variance);
|
|
633
|
+
const canvas = options.canvas || pack.canvas[0];
|
|
634
|
+
if (!pack.canvas.includes(canvas)) {
|
|
635
|
+
throw new Error(`Tonality "${pack.id}" supports ${pack.canvas.join(", ")}, not canvas ${canvas}`);
|
|
636
|
+
}
|
|
637
|
+
const tok = grid.densityTokens(density, pack.ramp);
|
|
638
|
+
const g = grid.makeGrid(canvas, tok.grid);
|
|
639
|
+
// Pretendard by default; the A2Z map only when the source asks for it (`faces: a2z`).
|
|
640
|
+
const wantsA2z = String(options.faces || "").toLowerCase() === "a2z" && pack["faces-a2z"];
|
|
641
|
+
const faceMap = wantsA2z ? pack["faces-a2z"] : pack.faces;
|
|
642
|
+
const faces = Object.fromEntries(FACE_KEYS.map((role) => [role, FACES[faceMap[role]]]));
|
|
643
|
+
const P = pack.palette;
|
|
644
|
+
const template = {
|
|
645
|
+
name: pack.name,
|
|
646
|
+
label: `${pack.name} tonality`,
|
|
647
|
+
version: pack.version,
|
|
648
|
+
fonts: { title: faces.title.font, body: faces.body.font, light: faces.body.font },
|
|
649
|
+
dimensions: { width: g.W / 72, height: g.H / 72, unit: "inches", format: canvas },
|
|
650
|
+
palette: {
|
|
651
|
+
ink: P.ink, ink_muted: P["ink-muted"], primary: P.accent, primary_deep: P["accent-deep"],
|
|
652
|
+
azure: P.series[1] || P.accent, azure_soft: P["accent-tint"], tint: P.surface, line: P.line, ground: P.ground, paper: P.ground,
|
|
653
|
+
},
|
|
654
|
+
global_typography: { char_spacing: 0, bullet_color: P.accent.replace("#", ""), section_accent: P.accent.replace("#", "") },
|
|
655
|
+
};
|
|
656
|
+
const pt = (v) => v / 72;
|
|
657
|
+
return {
|
|
658
|
+
name: pack.id,
|
|
659
|
+
version: pack.version,
|
|
660
|
+
template,
|
|
661
|
+
mapping: { layouts: {} },
|
|
662
|
+
capabilities: { font_roles: {} },
|
|
663
|
+
pack: {
|
|
664
|
+
...pack,
|
|
665
|
+
legacy: false,
|
|
666
|
+
capabilities: [],
|
|
667
|
+
faces,
|
|
668
|
+
dials: { density, variance, varianceMax: grid.varianceMax(variance, pack.treatments.length), canvas },
|
|
669
|
+
tok,
|
|
670
|
+
grid: g,
|
|
671
|
+
tokens: mergeTokens(LEGACY_TOKENS, {
|
|
672
|
+
card: { pad: pt(tok.pad), chip: 0, minH: 0, gap: pt(g.gutter), radius: pt(pack.radius) },
|
|
673
|
+
kpi: { minCardWidth: pt(g.span(1, 2).w), blockHeight: pt(tok.pad * 2 + tok.sizes.display * 1.15 + 6 + tok.sizes.label * 1.3), gap: pt(g.gutter) },
|
|
674
|
+
}),
|
|
675
|
+
},
|
|
352
676
|
};
|
|
353
677
|
}
|
|
354
678
|
|
|
355
|
-
module.exports = { loadTemplate, listTemplates };
|
|
679
|
+
module.exports = { loadTemplate, listTemplates, loadPack, listTonalities, loadTonality, hasCapability, validatePack, legacyPack, LEGACY_TOKENS };
|
|
@@ -11,6 +11,9 @@ exits non-zero if ANY real defect is found:
|
|
|
11
11
|
No LibreOffice / rendering engine required: everything is read from the OOXML
|
|
12
12
|
via python-pptx. (A renderer is only ever needed for *visual* preview, which is
|
|
13
13
|
optional and lives in thumbnail.py.)
|
|
14
|
+
|
|
15
|
+
`--sibling other.pptx` names the same source compiled under another tonality; the deck output checks
|
|
16
|
+
then fail the pair when they differ on fewer than two content slides (one in a deck of under four) (OF-116).
|
|
14
17
|
"""
|
|
15
18
|
|
|
16
19
|
from __future__ import annotations
|
|
@@ -28,7 +31,8 @@ from pptx import Presentation
|
|
|
28
31
|
from pptx.dml.color import RGBColor
|
|
29
32
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
30
33
|
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
|
|
31
|
-
from craft_extras import assess as assess_craft
|
|
34
|
+
from craft_extras import assess as assess_craft, display_slide
|
|
35
|
+
from deck_output import assess as assess_output, compare_skeletons
|
|
32
36
|
|
|
33
37
|
ROOT = Path(__file__).resolve().parents[1]
|
|
34
38
|
VALIDATE = ROOT / "scripts" / "validate_pptx.py"
|
|
@@ -94,10 +98,22 @@ def _bbox(shape):
|
|
|
94
98
|
return None
|
|
95
99
|
|
|
96
100
|
|
|
97
|
-
def
|
|
101
|
+
def _slide_ground(slide) -> tuple[int, int, int]:
|
|
102
|
+
"""The slide's own solid background colour; white when it sets none."""
|
|
103
|
+
try:
|
|
104
|
+
fill = slide.background.fill
|
|
105
|
+
if fill.type == 1: # MSO_FILL.SOLID
|
|
106
|
+
return _hex_to_rgb(fill.fore_color.rgb) or (255, 255, 255)
|
|
107
|
+
except (AttributeError, TypeError, ValueError):
|
|
108
|
+
pass
|
|
109
|
+
return (255, 255, 255)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve_bg(shape, idx, filled, ground=(255, 255, 255)):
|
|
98
113
|
"""Effective background for a text shape: its own fill, else the topmost
|
|
99
114
|
filled shape drawn *behind* it (lower z-order) that contains its centre,
|
|
100
|
-
else
|
|
115
|
+
else the slide's ground. Prevents false positives like white text over a
|
|
116
|
+
charcoal band or light type on a dark slide."""
|
|
101
117
|
own = _shape_fill_rgb(shape)
|
|
102
118
|
if own is not None:
|
|
103
119
|
return own
|
|
@@ -112,7 +128,7 @@ def _resolve_bg(shape, idx, filled):
|
|
|
112
128
|
best = f_rgb # later (higher idx) wins -> topmost behind text
|
|
113
129
|
if best is not None:
|
|
114
130
|
return best
|
|
115
|
-
return
|
|
131
|
+
return ground
|
|
116
132
|
|
|
117
133
|
|
|
118
134
|
def check_contrast(path: Path) -> dict[str, Any]:
|
|
@@ -123,10 +139,11 @@ def check_contrast(path: Path) -> dict[str, Any]:
|
|
|
123
139
|
for s_idx, slide in enumerate(prs.slides, start=1):
|
|
124
140
|
shapes = list(slide.shapes)
|
|
125
141
|
filled = [(i, rgb, _bbox(sh)) for i, sh in enumerate(shapes) if (rgb := _shape_fill_rgb(sh)) is not None]
|
|
142
|
+
ground = _slide_ground(slide)
|
|
126
143
|
for idx, shape in enumerate(shapes):
|
|
127
144
|
if not getattr(shape, "has_text_frame", False):
|
|
128
145
|
continue
|
|
129
|
-
bg = _resolve_bg(shape, idx, filled)
|
|
146
|
+
bg = _resolve_bg(shape, idx, filled, ground)
|
|
130
147
|
for para in shape.text_frame.paragraphs:
|
|
131
148
|
for run in para.runs:
|
|
132
149
|
if not run.text.strip():
|
|
@@ -448,7 +465,11 @@ def check_evidence_binding(path: Path) -> dict[str, Any]:
|
|
|
448
465
|
checked += 1
|
|
449
466
|
figure_box = _bbox_in(shape)
|
|
450
467
|
nearby: list[str] = []
|
|
451
|
-
|
|
468
|
+
page_area = (prs.slide_width / 914400.0) * (prs.slide_height / 914400.0)
|
|
469
|
+
if figure_box is not None and _is_full_bleed(figure_box, page_area):
|
|
470
|
+
# Nothing can sit under a full-bleed picture: its caption and source ride on the panel over it.
|
|
471
|
+
nearby = [text for other in shapes if other is not shape and (text := _shape_text(other))]
|
|
472
|
+
elif figure_box is not None:
|
|
452
473
|
figure_width = figure_box[2] - figure_box[0]
|
|
453
474
|
for other in shapes:
|
|
454
475
|
text = _shape_text(other)
|
|
@@ -621,8 +642,8 @@ def check_visual_substance(path: Path) -> dict[str, Any]:
|
|
|
621
642
|
if kind or shape.shape_type == MSO_SHAPE_TYPE.CHART:
|
|
622
643
|
boxes.append((max(0, x1), max(height * 0.18, y1),
|
|
623
644
|
min(width, x2), min(height * 0.91, y2)))
|
|
624
|
-
if slide_no == 1 or not boxes:
|
|
625
|
-
continue
|
|
645
|
+
if slide_no == 1 or not boxes or display_slide(slide):
|
|
646
|
+
continue # a cover, section, statement or quote holds a statement, not a body
|
|
626
647
|
body_slides += 1
|
|
627
648
|
if has_table and not any(sh.shape_type == MSO_SHAPE_TYPE.CHART for sh in slide.shapes):
|
|
628
649
|
table_slides += 1
|
|
@@ -695,7 +716,7 @@ def run_validate(pptx: Path) -> dict[str, Any]:
|
|
|
695
716
|
|
|
696
717
|
|
|
697
718
|
# ── gate ───────────────────────────────────────────────────────────────────
|
|
698
|
-
def qa(pptx: Path) -> dict[str, Any]:
|
|
719
|
+
def qa(pptx: Path, sibling: Path | None = None) -> dict[str, Any]:
|
|
699
720
|
validate_report = run_validate(pptx)
|
|
700
721
|
inv_summary = summarize_inventory(load_inventory_issues(pptx))
|
|
701
722
|
contrast = check_contrast(pptx)
|
|
@@ -704,6 +725,11 @@ def qa(pptx: Path) -> dict[str, Any]:
|
|
|
704
725
|
objective = check_objective_quality(pptx)
|
|
705
726
|
substance = check_visual_substance(pptx)
|
|
706
727
|
craft = assess_craft(Presentation(str(pptx)))
|
|
728
|
+
output = assess_output(Presentation(str(pptx)))
|
|
729
|
+
# The same source under another tonality, when given: the two decks differ on at least two content slides (OF-116).
|
|
730
|
+
if sibling is not None:
|
|
731
|
+
output["findings"] += compare_skeletons(Presentation(str(pptx)), Presentation(str(sibling)), sibling.name)
|
|
732
|
+
output["pass"] = not any(f["severity"] == "HIGH" for f in output["findings"])
|
|
707
733
|
|
|
708
734
|
reasons: list[str] = []
|
|
709
735
|
if not validate_report.get("pass", False):
|
|
@@ -743,6 +769,11 @@ def qa(pptx: Path) -> dict[str, Any]:
|
|
|
743
769
|
reasons.append(f"visual substance: {len(substance['findings'])} finding(s); see fix hints")
|
|
744
770
|
if not craft["pass"]:
|
|
745
771
|
reasons.append(f"office craft: {sum(f['severity'] == 'HIGH' for f in craft['findings'])} high finding(s)")
|
|
772
|
+
if not output["pass"]:
|
|
773
|
+
high = sorted({f["rule"] for f in output["findings"] if f["severity"] == "HIGH"})
|
|
774
|
+
reasons.append(f"deck output: {', '.join(high)} — vary title treatments and layout families by slide role, "
|
|
775
|
+
"fill the body and every region a title leaves, keep each treatment's frame, write titles as "
|
|
776
|
+
"topic labels, give a dark ground dark figures, and give each tonality its own structure")
|
|
746
777
|
|
|
747
778
|
return {
|
|
748
779
|
"file": str(pptx),
|
|
@@ -760,14 +791,16 @@ def qa(pptx: Path) -> dict[str, Any]:
|
|
|
760
791
|
"objective": objective,
|
|
761
792
|
"visual_substance": substance,
|
|
762
793
|
"office_craft": craft,
|
|
794
|
+
"deck_output": output,
|
|
763
795
|
}
|
|
764
796
|
|
|
765
797
|
|
|
766
798
|
def main(argv: list[str] | None = None) -> int:
|
|
767
799
|
parser = argparse.ArgumentParser(description="Honest renderer-agnostic QA gate for PPTX decks.")
|
|
768
800
|
parser.add_argument("pptx", type=Path)
|
|
801
|
+
parser.add_argument("--sibling", type=Path, help="the same source compiled under another tonality (OF-116)")
|
|
769
802
|
args = parser.parse_args(argv)
|
|
770
|
-
report = qa(args.pptx.resolve())
|
|
803
|
+
report = qa(args.pptx.resolve(), args.sibling.resolve() if args.sibling else None)
|
|
771
804
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
772
805
|
return 0 if report["pass"] else 1
|
|
773
806
|
|
|
@@ -8,7 +8,7 @@ const require = createRequire(import.meta.url);
|
|
|
8
8
|
const deps = require('./deps.cjs');
|
|
9
9
|
const directory = dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
const [name, ...args] = process.argv.slice(2);
|
|
11
|
-
const allowed = new Set(['qa_deck.py', 'validate_pptx.py', 'inventory.py', 'ooxml_integrity.py', 'embed_fonts.py', 'learn_template.py']);
|
|
11
|
+
const allowed = new Set(['qa_deck.py', 'validate_pptx.py', 'inventory.py', 'ooxml_integrity.py', 'embed_fonts.py', 'learn_template.py', 'deck_output.py', 'craft_extras.py']);
|
|
12
12
|
|
|
13
13
|
if (name === 'doctor') {
|
|
14
14
|
console.log(`node: ${deps.supportsSlideNode() ? (deps.ready('node') ? 'ready' : 'first-use install needed') : 'Node.js 20.9+ required for slides'}`);
|
|
@@ -18,7 +18,8 @@ if (name === 'doctor') {
|
|
|
18
18
|
console.log(`${executable}: ${result.status === 0 ? result.stdout.trim() : 'unavailable (optional)'}`);
|
|
19
19
|
}
|
|
20
20
|
} else if (allowed.has(name) && existsSync(join(directory, name))) {
|
|
21
|
-
|
|
21
|
+
// The installed skill folder stays free of bytecode caches.
|
|
22
|
+
const result = spawnSync(deps.python(), [join(directory, name), ...args], { stdio: 'inherit', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
|
|
22
23
|
process.exit(result.status ?? 1);
|
|
23
24
|
} else {
|
|
24
25
|
console.error('Usage: node run.mjs doctor | <packaged Python script> [arguments]');
|