@litfamily/litopencode 1.0.16 → 1.0.17
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/CHANGELOG.md +7 -0
- package/README-Ko-KR.md +11 -11
- package/README.md +11 -11
- package/dist/activation-routing.js +1 -1
- package/dist/features.js +2 -2
- package/dist/skills.js +6 -4
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference-Ko-KR.md +1 -1
- package/package.json +1 -1
- package/skills/frontend-ui-ux/data/THIRD-PARTY-NOTICE.txt +1 -3
- package/skills/lit-docx/SKILL.md +80 -19
- package/skills/lit-docx/references/components.md +253 -0
- package/skills/lit-docx/references/direction-step.md +234 -0
- package/skills/lit-docx/references/examples/01-report-ko.md +160 -0
- package/skills/lit-docx/references/examples/02-brief-ko.md +58 -0
- package/skills/lit-docx/references/examples/03-manual-ko.md +107 -0
- package/skills/lit-docx/references/examples/04-proposal-ko.md +93 -0
- package/skills/lit-docx/references/examples/05-memo-ko.md +42 -0
- package/skills/lit-docx/references/examples/06-journal-en.md +84 -0
- package/skills/lit-docx/references/examples/07-report-en.md +137 -0
- package/skills/lit-docx/references/examples/08-journal-ko.md +93 -0
- package/skills/lit-docx/references/examples/09-brief-en.md +64 -0
- package/skills/lit-docx/references/page-composition.md +306 -0
- package/skills/lit-docx/references/tonalities/brief.md +104 -0
- package/skills/lit-docx/references/tonalities/journal.md +101 -0
- package/skills/lit-docx/references/tonalities/manual.md +111 -0
- package/skills/lit-docx/references/tonalities/memo.md +105 -0
- package/skills/lit-docx/references/tonalities/proposal.md +101 -0
- package/skills/lit-docx/references/tonalities/report.md +126 -0
- package/skills/lit-docx/run.py +2 -1
- package/skills/lit-docx/scripts/convert_md_to_docx.py +384 -67
- package/skills/lit-docx/scripts/craft_docx.py +204 -1
- package/skills/lit-docx/scripts/docx_design.py +2445 -0
- package/skills/lit-docx/scripts/docx_layout.py +959 -0
- package/skills/lit-docx/scripts/slop_lint.py +23 -6
- package/skills/lit-docx/templates/tonalities/brief.yaml +29 -0
- package/skills/lit-docx/templates/tonalities/journal.yaml +26 -0
- package/skills/lit-docx/templates/tonalities/manual.yaml +27 -0
- package/skills/lit-docx/templates/tonalities/memo.yaml +27 -0
- package/skills/lit-docx/templates/tonalities/proposal.yaml +27 -0
- package/skills/lit-docx/templates/tonalities/report.yaml +27 -0
- package/skills/lit-pptx/NOTICE.md +1 -1
- package/skills/lit-pptx/SKILL.md +108 -19
- package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
- package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
- package/skills/lit-pptx/references/density-and-fill.md +283 -0
- package/skills/lit-pptx/references/direction-step.md +249 -0
- package/skills/lit-pptx/references/examples/01-ops-review-ledger-ko.md +173 -0
- package/skills/lit-pptx/references/examples/02-launch-pitch-signal-en.md +153 -0
- package/skills/lit-pptx/references/examples/03-field-survey-atlas-ko.md +120 -0
- package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +132 -0
- package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +162 -0
- package/skills/lit-pptx/references/examples/06-policy-briefing-gazette-ko.md +176 -0
- package/skills/lit-pptx/references/examples/07-annual-story-studio-en.md +131 -0
- package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +182 -0
- package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +147 -0
- package/skills/lit-pptx/references/examples/10-investor-pitch-signal-ko.md +136 -0
- package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +177 -0
- package/skills/lit-pptx/references/examples/12-exhibition-proposal-studio-ko.md +149 -0
- package/skills/lit-pptx/references/layout-families.md +470 -0
- package/skills/lit-pptx/references/title-treatments.md +271 -0
- package/skills/lit-pptx/references/tonalities/atlas.md +132 -0
- package/skills/lit-pptx/references/tonalities/chalk.md +132 -0
- package/skills/lit-pptx/references/tonalities/gazette.md +137 -0
- package/skills/lit-pptx/references/tonalities/ledger.md +151 -0
- package/skills/lit-pptx/references/tonalities/night.md +148 -0
- package/skills/lit-pptx/references/tonalities/paper.md +136 -0
- package/skills/lit-pptx/references/tonalities/signal.md +148 -0
- package/skills/lit-pptx/references/tonalities/studio.md +149 -0
- package/skills/lit-pptx/scripts/compile-deck.js +68 -21
- package/skills/lit-pptx/scripts/craft-thresholds.json +45 -1
- package/skills/lit-pptx/scripts/craft_extras.py +37 -8
- package/skills/lit-pptx/scripts/deck_output.py +654 -0
- package/skills/lit-pptx/scripts/embed_fonts.py +2 -2
- package/skills/lit-pptx/scripts/layout_inventory.py +35 -2
- package/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
- package/skills/lit-pptx/scripts/lib/font-map.js +4 -4
- package/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
- package/skills/lit-pptx/scripts/lib/layout-resolver.js +403 -21
- package/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
- package/skills/lit-pptx/scripts/lib/render-adapter-html.js +3 -9
- package/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -113
- package/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
- package/skills/lit-pptx/scripts/lib/slide-ast.js +55 -24
- package/skills/lit-pptx/scripts/lib/spec-validator.js +2 -2
- package/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
- package/skills/lit-pptx/scripts/qa_deck.py +48 -13
- package/skills/lit-pptx/scripts/validate_pptx.py +17 -5
- package/skills/lit-pptx/specs/template-enrollment-contract.md +4 -4
- package/skills/lit-pptx/templates/boilerplate_professional.css +13 -13
- package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
- package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +5 -2
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/capabilities.yaml +20 -20
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -11
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +10 -7
- package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/capabilities.yaml +17 -17
- package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/template.yaml +8 -8
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +105 -0
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +110 -0
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +106 -0
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +107 -0
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +106 -0
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +111 -0
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +107 -0
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +105 -0
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +2 -2
- package/skills/lit-typographic-motion/engine/treatment.mjs +1 -1
- package/skills/lit-typographic-motion/fonts/pins.json +2 -2
- package/skills/lit-typographic-motion/gate.mjs +1 -1
- package/skills/lit-typographic-motion/references/stage.md +1 -1
- package/skills/lit-typographic-motion/references/style-bibles.md +2 -2
- package/skills/lit-typographic-motion/references/treatment.md +1 -1
- package/skills/lit-typographic-motion/references/type-craft.md +3 -3
- package/skills/managed-skill-manifest.json +288 -44
- package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Bold.otf +0 -0
- package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Regular.otf +0 -0
|
@@ -28,7 +28,7 @@ const DEFAULT_COLUMN_GAP = 0.25;
|
|
|
28
28
|
|
|
29
29
|
/** Layout names v1 shipped with; anything else marks a deck as v2. */
|
|
30
30
|
const V1_LAYOUTS = new Set(["cover", "content", "main", "summary", "closing", "section"]);
|
|
31
|
-
const V2_BLOCK_TYPES = new Set(["columns", "box", "shape", "col", "region"
|
|
31
|
+
const V2_BLOCK_TYPES = new Set(["columns", "box", "shape", "col", "region"]);
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Build AST blocks for a slide.
|
|
@@ -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
|
}
|
|
@@ -394,23 +398,6 @@ function buildDirectiveBlocks(seg, layout) {
|
|
|
394
398
|
}];
|
|
395
399
|
}
|
|
396
400
|
|
|
397
|
-
case "chart": {
|
|
398
|
-
const table = parseTable(value);
|
|
399
|
-
const kind = String(attrs.type || "bar");
|
|
400
|
-
if (!table || table.headers.length < 2 || table.rows.length < 2 || !["bar", "line"].includes(kind)) {
|
|
401
|
-
throw new Error('"::: chart" requires type=bar|line and a pipe table with a category column and at least two data rows.');
|
|
402
|
-
}
|
|
403
|
-
const labels = table.rows.map((row) => row[0]);
|
|
404
|
-
const series = table.headers.slice(1).map((name, index) => ({
|
|
405
|
-
name,
|
|
406
|
-
values: table.rows.map((row) => Number(String(row[index + 1]).replace(/,/g, ""))),
|
|
407
|
-
}));
|
|
408
|
-
if (series.some((item) => item.values.some((number) => !Number.isFinite(number)))) {
|
|
409
|
-
throw new Error('"::: chart" data cells must contain finite numbers.');
|
|
410
|
-
}
|
|
411
|
-
return [{ type: "chart", chartType: kind, labels, series }];
|
|
412
|
-
}
|
|
413
|
-
|
|
414
401
|
case "shape": {
|
|
415
402
|
const kind = positional[0];
|
|
416
403
|
if (!SHAPE_KINDS.has(kind)) {
|
|
@@ -449,11 +436,55 @@ function buildDirectiveBlocks(seg, layout) {
|
|
|
449
436
|
return [block];
|
|
450
437
|
}
|
|
451
438
|
|
|
439
|
+
case "chart":
|
|
440
|
+
return buildChartBlocks(value, attrs, positional);
|
|
441
|
+
|
|
452
442
|
default:
|
|
453
443
|
return [buildDirectiveBlock(directive, value)];
|
|
454
444
|
}
|
|
455
445
|
}
|
|
456
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
|
+
|
|
457
488
|
/**
|
|
458
489
|
* Build a directive block from name and content.
|
|
459
490
|
*/
|
|
@@ -25,8 +25,8 @@ const PLACEMENT_BLOCKS = new Set(["box", "shape", "columns"]);
|
|
|
25
25
|
|
|
26
26
|
const LAYOUT_BLOCKS = {
|
|
27
27
|
cover: new Set(["title"]),
|
|
28
|
-
content: new Set(["title", "key-message", "body", "image", "notes", "kpi-table", "
|
|
29
|
-
main: new Set(["title", "key-message", "body", "main-box", "kpi-table", "
|
|
28
|
+
content: new Set(["title", "key-message", "body", "image", "notes", "kpi-table", "figure-caption", "table-caption"]),
|
|
29
|
+
main: new Set(["title", "key-message", "body", "main-box", "kpi-table", "image", "figure-caption", "table-caption"]),
|
|
30
30
|
summary: new Set(["title", "key-message", "summary-group", "image", "kpi-table", "figure-caption", "table-caption"]),
|
|
31
31
|
closing: new Set(["title"]),
|
|
32
32
|
section: new Set(["title", "notes"]),
|
|
@@ -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 };
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Honest, renderer-agnostic QA gate for generated PPTX decks.
|
|
3
3
|
|
|
4
|
-
Brand-neutral: works for any enrolled template. Combines
|
|
4
|
+
Brand-neutral: works for any enrolled template. Combines these checks and
|
|
5
5
|
exits non-zero if ANY real defect is found:
|
|
6
6
|
|
|
7
7
|
1. Layout issues — inventory.py --issues-only (overflow / overlap / off-slide)
|
|
8
8
|
2. Anti-slop — validate_pptx.py (forbidden/placeholder terms, default fonts)
|
|
9
9
|
3. WCAG contrast — text colour vs its fill (body < 4.5:1, large < 3:1)
|
|
10
|
+
4. Craft floor — craft_extras.py (OF-101..OF-109 per slide) and deck_output.py
|
|
11
|
+
(OF-110..OF-117 over the deck: title-treatment count, composition
|
|
12
|
+
variety, empty body band, one frame per treatment, label titles,
|
|
13
|
+
no empty region, no light figure card on a dark ground; with
|
|
14
|
+
--sibling, OF-116: another tonality of the same source must draw
|
|
15
|
+
a different skeleton)
|
|
10
16
|
|
|
11
17
|
No LibreOffice / rendering engine required: everything is read from the OOXML
|
|
12
18
|
via python-pptx. (A renderer is only ever needed for *visual* preview, which is
|
|
@@ -29,6 +35,7 @@ from pptx.dml.color import RGBColor
|
|
|
29
35
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
30
36
|
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
|
|
31
37
|
from craft_extras import analyse as analyse_craft
|
|
38
|
+
from deck_output import compare_skeletons
|
|
32
39
|
|
|
33
40
|
ROOT = Path(__file__).resolve().parents[1]
|
|
34
41
|
VALIDATE = ROOT / "scripts" / "validate_pptx.py"
|
|
@@ -94,10 +101,22 @@ def _bbox(shape):
|
|
|
94
101
|
return None
|
|
95
102
|
|
|
96
103
|
|
|
97
|
-
def
|
|
104
|
+
def _slide_ground(slide) -> tuple[int, int, int]:
|
|
105
|
+
"""The slide's own solid background, white when it has none (or inherits one)."""
|
|
106
|
+
try:
|
|
107
|
+
fill = slide.background.fill
|
|
108
|
+
if fill.type == 1: # solid
|
|
109
|
+
return _hex_to_rgb(fill.fore_color.rgb) or (255, 255, 255)
|
|
110
|
+
except (AttributeError, TypeError, ValueError):
|
|
111
|
+
pass
|
|
112
|
+
return (255, 255, 255)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resolve_bg(shape, idx, filled, ground=(255, 255, 255)):
|
|
98
116
|
"""Effective background for a text shape: its own fill, else the topmost
|
|
99
117
|
filled shape drawn *behind* it (lower z-order) that contains its centre,
|
|
100
|
-
else
|
|
118
|
+
else the slide background. Prevents false positives like white text over a
|
|
119
|
+
charcoal band, or light text on a dark slide."""
|
|
101
120
|
own = _shape_fill_rgb(shape)
|
|
102
121
|
if own is not None:
|
|
103
122
|
return own
|
|
@@ -112,7 +131,7 @@ def _resolve_bg(shape, idx, filled):
|
|
|
112
131
|
best = f_rgb # later (higher idx) wins -> topmost behind text
|
|
113
132
|
if best is not None:
|
|
114
133
|
return best
|
|
115
|
-
return
|
|
134
|
+
return ground
|
|
116
135
|
|
|
117
136
|
|
|
118
137
|
def check_contrast(path: Path) -> dict[str, Any]:
|
|
@@ -123,10 +142,11 @@ def check_contrast(path: Path) -> dict[str, Any]:
|
|
|
123
142
|
for s_idx, slide in enumerate(prs.slides, start=1):
|
|
124
143
|
shapes = list(slide.shapes)
|
|
125
144
|
filled = [(i, rgb, _bbox(sh)) for i, sh in enumerate(shapes) if (rgb := _shape_fill_rgb(sh)) is not None]
|
|
145
|
+
ground = _slide_ground(slide)
|
|
126
146
|
for idx, shape in enumerate(shapes):
|
|
127
147
|
if not getattr(shape, "has_text_frame", False):
|
|
128
148
|
continue
|
|
129
|
-
bg = _resolve_bg(shape, idx, filled)
|
|
149
|
+
bg = _resolve_bg(shape, idx, filled, ground)
|
|
130
150
|
for para in shape.text_frame.paragraphs:
|
|
131
151
|
for run in para.runs:
|
|
132
152
|
if not run.text.strip():
|
|
@@ -444,7 +464,11 @@ def check_evidence_binding(path: Path) -> dict[str, Any]:
|
|
|
444
464
|
checked += 1
|
|
445
465
|
figure_box = _bbox_in(shape)
|
|
446
466
|
nearby: list[str] = []
|
|
447
|
-
|
|
467
|
+
page_area = (prs.slide_width / 914400.0) * (prs.slide_height / 914400.0)
|
|
468
|
+
if figure_box is not None and _is_full_bleed(figure_box, page_area):
|
|
469
|
+
# A full-bleed picture carries its caption on the panel laid over it.
|
|
470
|
+
nearby = [text for other in shapes if other is not shape and (text := _shape_text(other))]
|
|
471
|
+
elif figure_box is not None:
|
|
448
472
|
figure_width = figure_box[2] - figure_box[0]
|
|
449
473
|
for other in shapes:
|
|
450
474
|
text = _shape_text(other)
|
|
@@ -593,6 +617,12 @@ def check_composition(path: Path) -> dict[str, Any]:
|
|
|
593
617
|
for slide_no, slide in enumerate(prs.slides, 1):
|
|
594
618
|
content = []
|
|
595
619
|
table_count = chart_count = 0
|
|
620
|
+
# The content area runs under a top title; a bottom title closes it from below, so it starts at the top margin.
|
|
621
|
+
title = next((shape for shape in slide.shapes if shape.name == "title@bottom-anchor" and shape.top is not None), None)
|
|
622
|
+
top, floor = (0.45, title.top / 914400) if title is not None else (1.7, 6.7)
|
|
623
|
+
held = [box for shape in slide.shapes if (box := _bbox_in(shape)) is not None and (
|
|
624
|
+
getattr(shape, "has_table", False) or getattr(shape, "has_chart", False)
|
|
625
|
+
or shape.shape_type == MSO_SHAPE_TYPE.PICTURE or _shape_text(shape))]
|
|
596
626
|
for shape_no, shape in enumerate(slide.shapes, 1):
|
|
597
627
|
box = _bbox_in(shape)
|
|
598
628
|
if box is None:
|
|
@@ -607,13 +637,14 @@ def check_composition(path: Path) -> dict[str, Any]:
|
|
|
607
637
|
chart_count += has_chart
|
|
608
638
|
if (has_table or has_chart or shape.shape_type == MSO_SHAPE_TYPE.PICTURE or
|
|
609
639
|
(getattr(shape, "has_text_frame", False) and shape.text_frame.text.strip())):
|
|
610
|
-
if
|
|
611
|
-
content.append((max(0.7, x0), max(
|
|
640
|
+
if top <= y0 < floor:
|
|
641
|
+
content.append((max(0.7, x0), max(top, y0), min(width - 0.7, x1), min(floor, y1)))
|
|
612
642
|
if (not _shape_text(shape) and not has_table and not has_chart and
|
|
613
643
|
shape.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE and
|
|
614
644
|
(x1 - x0) * (y1 - y0) > 0.3 and
|
|
615
|
-
_shape_fill_rgb(shape) is None
|
|
616
|
-
|
|
645
|
+
_shape_fill_rgb(shape) is None and
|
|
646
|
+
not any(x0 <= a + 0.03 and y0 <= b + 0.03 and c <= x1 + 0.03 and d <= y1 + 0.03 for a, b, c, d in held)):
|
|
647
|
+
# A large empty outlined rectangle is usually a leftover box; one around text is its frame.
|
|
617
648
|
try:
|
|
618
649
|
line_rgb = _hex_to_rgb(shape.line.color.rgb)
|
|
619
650
|
except (TypeError, AttributeError):
|
|
@@ -622,7 +653,7 @@ def check_composition(path: Path) -> dict[str, Any]:
|
|
|
622
653
|
issues.append({"slide": slide_no, "shape": shape_no, "kind": "empty_outline",
|
|
623
654
|
"hint": "Remove the unused outlined box or put its intended content inside it."})
|
|
624
655
|
if 1 < slide_no < len(prs.slides):
|
|
625
|
-
area = (width - 1.4) *
|
|
656
|
+
area = (width - 1.4) * (floor - top)
|
|
626
657
|
occupied = sum(max(0, x1 - x0) * max(0, y1 - y0) for x0, y0, x1, y1 in content)
|
|
627
658
|
ratio = round(min(1, occupied / area), 3)
|
|
628
659
|
fill_ratios.append({"slide": slide_no, "ratio": ratio})
|
|
@@ -692,7 +723,7 @@ def run_validate(pptx: Path) -> dict[str, Any]:
|
|
|
692
723
|
|
|
693
724
|
|
|
694
725
|
# ── gate ───────────────────────────────────────────────────────────────────
|
|
695
|
-
def qa(pptx: Path) -> dict[str, Any]:
|
|
726
|
+
def qa(pptx: Path, sibling: Path | None = None) -> dict[str, Any]:
|
|
696
727
|
validate_report = run_validate(pptx)
|
|
697
728
|
inv_summary = summarize_inventory(load_inventory_issues(pptx))
|
|
698
729
|
contrast = check_contrast(pptx)
|
|
@@ -701,6 +732,9 @@ def qa(pptx: Path) -> dict[str, Any]:
|
|
|
701
732
|
objective = check_objective_quality(pptx)
|
|
702
733
|
composition = check_composition(pptx)
|
|
703
734
|
craft = analyse_craft(pptx)
|
|
735
|
+
if sibling is not None:
|
|
736
|
+
craft["findings"] += compare_skeletons(pptx, sibling)
|
|
737
|
+
craft["pass"] = not any(item["severity"] == "HIGH" for item in craft["findings"])
|
|
704
738
|
|
|
705
739
|
reasons: list[str] = []
|
|
706
740
|
if not validate_report.get("pass", False):
|
|
@@ -763,8 +797,9 @@ def qa(pptx: Path) -> dict[str, Any]:
|
|
|
763
797
|
def main(argv: list[str] | None = None) -> int:
|
|
764
798
|
parser = argparse.ArgumentParser(description="Honest renderer-agnostic QA gate for PPTX decks.")
|
|
765
799
|
parser.add_argument("pptx", type=Path)
|
|
800
|
+
parser.add_argument("--sibling", type=Path, help="the same source compiled under another tonality (OF-116)")
|
|
766
801
|
args = parser.parse_args(argv)
|
|
767
|
-
report = qa(args.pptx.resolve())
|
|
802
|
+
report = qa(args.pptx.resolve(), args.sibling.resolve() if args.sibling else None)
|
|
768
803
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
769
804
|
return 0 if report["pass"] else 1
|
|
770
805
|
|