@litfamily/litopencode 1.0.15 → 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.
Files changed (118) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README-Ko-KR.md +11 -11
  3. package/README.md +11 -11
  4. package/dist/activation-routing.js +1 -1
  5. package/dist/auto-handoff.js +34 -1
  6. package/dist/features.js +2 -2
  7. package/dist/skills.js +6 -4
  8. package/docs/assets/readme/badge-version.svg +1 -1
  9. package/docs/reference-Ko-KR.md +1 -1
  10. package/package.json +1 -1
  11. package/skills/frontend-ui-ux/data/THIRD-PARTY-NOTICE.txt +1 -3
  12. package/skills/lit-docx/SKILL.md +80 -19
  13. package/skills/lit-docx/references/components.md +253 -0
  14. package/skills/lit-docx/references/direction-step.md +234 -0
  15. package/skills/lit-docx/references/examples/01-report-ko.md +160 -0
  16. package/skills/lit-docx/references/examples/02-brief-ko.md +58 -0
  17. package/skills/lit-docx/references/examples/03-manual-ko.md +107 -0
  18. package/skills/lit-docx/references/examples/04-proposal-ko.md +93 -0
  19. package/skills/lit-docx/references/examples/05-memo-ko.md +42 -0
  20. package/skills/lit-docx/references/examples/06-journal-en.md +84 -0
  21. package/skills/lit-docx/references/examples/07-report-en.md +137 -0
  22. package/skills/lit-docx/references/examples/08-journal-ko.md +93 -0
  23. package/skills/lit-docx/references/examples/09-brief-en.md +64 -0
  24. package/skills/lit-docx/references/page-composition.md +306 -0
  25. package/skills/lit-docx/references/tonalities/brief.md +104 -0
  26. package/skills/lit-docx/references/tonalities/journal.md +101 -0
  27. package/skills/lit-docx/references/tonalities/manual.md +111 -0
  28. package/skills/lit-docx/references/tonalities/memo.md +105 -0
  29. package/skills/lit-docx/references/tonalities/proposal.md +101 -0
  30. package/skills/lit-docx/references/tonalities/report.md +126 -0
  31. package/skills/lit-docx/run.py +2 -1
  32. package/skills/lit-docx/scripts/convert_md_to_docx.py +384 -67
  33. package/skills/lit-docx/scripts/craft_docx.py +204 -1
  34. package/skills/lit-docx/scripts/docx_design.py +2445 -0
  35. package/skills/lit-docx/scripts/docx_layout.py +959 -0
  36. package/skills/lit-docx/scripts/slop_lint.py +23 -6
  37. package/skills/lit-docx/templates/tonalities/brief.yaml +29 -0
  38. package/skills/lit-docx/templates/tonalities/journal.yaml +26 -0
  39. package/skills/lit-docx/templates/tonalities/manual.yaml +27 -0
  40. package/skills/lit-docx/templates/tonalities/memo.yaml +27 -0
  41. package/skills/lit-docx/templates/tonalities/proposal.yaml +27 -0
  42. package/skills/lit-docx/templates/tonalities/report.yaml +27 -0
  43. package/skills/lit-pptx/NOTICE.md +1 -1
  44. package/skills/lit-pptx/SKILL.md +108 -19
  45. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
  46. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
  47. package/skills/lit-pptx/references/density-and-fill.md +283 -0
  48. package/skills/lit-pptx/references/direction-step.md +249 -0
  49. package/skills/lit-pptx/references/examples/01-ops-review-ledger-ko.md +173 -0
  50. package/skills/lit-pptx/references/examples/02-launch-pitch-signal-en.md +153 -0
  51. package/skills/lit-pptx/references/examples/03-field-survey-atlas-ko.md +120 -0
  52. package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +132 -0
  53. package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +162 -0
  54. package/skills/lit-pptx/references/examples/06-policy-briefing-gazette-ko.md +176 -0
  55. package/skills/lit-pptx/references/examples/07-annual-story-studio-en.md +131 -0
  56. package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +182 -0
  57. package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +147 -0
  58. package/skills/lit-pptx/references/examples/10-investor-pitch-signal-ko.md +136 -0
  59. package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +177 -0
  60. package/skills/lit-pptx/references/examples/12-exhibition-proposal-studio-ko.md +149 -0
  61. package/skills/lit-pptx/references/layout-families.md +470 -0
  62. package/skills/lit-pptx/references/title-treatments.md +271 -0
  63. package/skills/lit-pptx/references/tonalities/atlas.md +132 -0
  64. package/skills/lit-pptx/references/tonalities/chalk.md +132 -0
  65. package/skills/lit-pptx/references/tonalities/gazette.md +137 -0
  66. package/skills/lit-pptx/references/tonalities/ledger.md +151 -0
  67. package/skills/lit-pptx/references/tonalities/night.md +148 -0
  68. package/skills/lit-pptx/references/tonalities/paper.md +136 -0
  69. package/skills/lit-pptx/references/tonalities/signal.md +148 -0
  70. package/skills/lit-pptx/references/tonalities/studio.md +149 -0
  71. package/skills/lit-pptx/scripts/compile-deck.js +68 -21
  72. package/skills/lit-pptx/scripts/craft-thresholds.json +45 -1
  73. package/skills/lit-pptx/scripts/craft_extras.py +37 -8
  74. package/skills/lit-pptx/scripts/deck_output.py +654 -0
  75. package/skills/lit-pptx/scripts/embed_fonts.py +2 -2
  76. package/skills/lit-pptx/scripts/layout_inventory.py +35 -2
  77. package/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
  78. package/skills/lit-pptx/scripts/lib/font-map.js +4 -4
  79. package/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  80. package/skills/lit-pptx/scripts/lib/layout-resolver.js +403 -21
  81. package/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  82. package/skills/lit-pptx/scripts/lib/render-adapter-html.js +3 -9
  83. package/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -113
  84. package/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
  85. package/skills/lit-pptx/scripts/lib/slide-ast.js +55 -24
  86. package/skills/lit-pptx/scripts/lib/spec-validator.js +2 -2
  87. package/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
  88. package/skills/lit-pptx/scripts/qa_deck.py +48 -13
  89. package/skills/lit-pptx/scripts/validate_pptx.py +17 -5
  90. package/skills/lit-pptx/specs/template-enrollment-contract.md +4 -4
  91. package/skills/lit-pptx/templates/boilerplate_professional.css +13 -13
  92. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  93. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +5 -2
  94. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/capabilities.yaml +20 -20
  95. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -11
  96. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +10 -7
  97. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/capabilities.yaml +17 -17
  98. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/template.yaml +8 -8
  99. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +105 -0
  100. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +110 -0
  101. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +106 -0
  102. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +107 -0
  103. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +106 -0
  104. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +111 -0
  105. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +107 -0
  106. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +105 -0
  107. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
  108. package/skills/lit-typographic-motion/engine/stage/capture.mjs +2 -2
  109. package/skills/lit-typographic-motion/engine/treatment.mjs +1 -1
  110. package/skills/lit-typographic-motion/fonts/pins.json +2 -2
  111. package/skills/lit-typographic-motion/gate.mjs +1 -1
  112. package/skills/lit-typographic-motion/references/stage.md +1 -1
  113. package/skills/lit-typographic-motion/references/style-bibles.md +2 -2
  114. package/skills/lit-typographic-motion/references/treatment.md +1 -1
  115. package/skills/lit-typographic-motion/references/type-craft.md +3 -3
  116. package/skills/managed-skill-manifest.json +288 -44
  117. package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Bold.otf +0 -0
  118. 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", "chart"]);
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 img = parseImage(remaining);
144
- if (img && !remaining.split("\n").some((l) => l.trim() && !l.trim().startsWith("!["))) {
145
- blocks.push({ type: "image", src: img.src, caption: img.alt || "" });
146
- if (img.alt) {
147
- blocks.push({ type: "figure-caption", caption: img.alt });
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", "chart", "figure-caption", "table-caption"]),
29
- main: new Set(["title", "key-message", "body", "main-box", "kpi-table", "chart", "image", "figure-caption", "table-caption"]),
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
- if (!fs.existsSync(ENROLLED_DIR)) return [];
316
- return fs
317
- .readdirSync(ENROLLED_DIR)
318
- .filter((name) =>
319
- fs.statSync(path.join(ENROLLED_DIR, name)).isDirectory()
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. "AZURE-PRO")
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
- const dir = path.join(ENROLLED_DIR, name);
330
- if (!fs.existsSync(dir)) {
331
- throw new Error(`Unknown template: "${name}" not found in enrolled templates`);
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 three checks and
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 _resolve_bg(shape, idx, filled):
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 white. Prevents false positives like white text over a charcoal band."""
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 (255, 255, 255)
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
- if figure_box is not None:
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 y0 >= 1.7 and y0 < 6.7:
611
- content.append((max(0.7, x0), max(1.7, y0), min(width - 0.7, x1), min(6.7, y1)))
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
- # A large empty outlined rectangle is usually a leftover box.
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) * 5.0
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