@litfamily/litclaude 1.0.19 → 1.0.21

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 (101) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -8
  3. package/README_ko-KR.md +13 -8
  4. package/docs/assets/readme/badge-version.svg +1 -1
  5. package/docs/hooks.md +3 -2
  6. package/package.json +1 -1
  7. package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
  8. package/plugins/litclaude/bin/litclaude-hook.js +4 -4
  9. package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
  10. package/plugins/litclaude/lib/canonical-skill-resources.mjs +91 -31
  11. package/plugins/litclaude/skills/frontend-ui-ux/ATTRIBUTION.md +3 -6
  12. package/plugins/litclaude/skills/lit-docx/SKILL.md +50 -19
  13. package/plugins/litclaude/skills/lit-docx/references/components.md +207 -0
  14. package/plugins/litclaude/skills/lit-docx/references/direction-step.md +277 -0
  15. package/plugins/litclaude/skills/lit-docx/references/examples/01-report-ko.md +166 -0
  16. package/plugins/litclaude/skills/lit-docx/references/examples/02-brief-ko.md +105 -0
  17. package/plugins/litclaude/skills/lit-docx/references/examples/03-manual-ko.md +164 -0
  18. package/plugins/litclaude/skills/lit-docx/references/examples/04-proposal-ko.md +138 -0
  19. package/plugins/litclaude/skills/lit-docx/references/examples/05-memo-en.md +55 -0
  20. package/plugins/litclaude/skills/lit-docx/references/examples/06-journal-en.md +130 -0
  21. package/plugins/litclaude/skills/lit-docx/references/examples/07-report-en.md +123 -0
  22. package/plugins/litclaude/skills/lit-docx/references/examples/08-proposal-en.md +123 -0
  23. package/plugins/litclaude/skills/lit-docx/references/examples/09-memo-ko.md +45 -0
  24. package/plugins/litclaude/skills/lit-docx/references/page-composition.md +221 -0
  25. package/plugins/litclaude/skills/lit-docx/references/tonalities/brief.md +143 -0
  26. package/plugins/litclaude/skills/lit-docx/references/tonalities/journal.md +142 -0
  27. package/plugins/litclaude/skills/lit-docx/references/tonalities/manual.md +148 -0
  28. package/plugins/litclaude/skills/lit-docx/references/tonalities/memo.md +137 -0
  29. package/plugins/litclaude/skills/lit-docx/references/tonalities/proposal.md +140 -0
  30. package/plugins/litclaude/skills/lit-docx/references/tonalities/report.md +156 -0
  31. package/plugins/litclaude/skills/lit-docx/scripts/convert_md_to_docx.py +274 -73
  32. package/plugins/litclaude/skills/lit-docx/scripts/docx_design.py +2449 -0
  33. package/plugins/litclaude/skills/lit-docx/scripts/docx_layout.py +959 -0
  34. package/plugins/litclaude/skills/lit-docx/scripts/qa_docx.py +66 -9
  35. package/plugins/litclaude/skills/lit-docx/scripts/slop_lint.py +18 -6
  36. package/plugins/litclaude/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
  37. package/plugins/litclaude/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
  38. package/plugins/litclaude/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
  39. package/plugins/litclaude/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
  40. package/plugins/litclaude/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
  41. package/plugins/litclaude/skills/lit-docx/templates/tonalities/report.yaml +26 -0
  42. package/plugins/litclaude/skills/lit-pptx/SKILL.md +105 -48
  43. package/plugins/litclaude/skills/lit-pptx/references/agents/analyst.md +1 -1
  44. package/plugins/litclaude/skills/lit-pptx/references/agents/thesis.md +1 -1
  45. package/plugins/litclaude/skills/lit-pptx/references/anti-slop-checklist.md +59 -30
  46. package/plugins/litclaude/skills/lit-pptx/references/authoring-guide.md +119 -42
  47. package/plugins/litclaude/skills/lit-pptx/references/consensus-protocol.md +1 -1
  48. package/plugins/litclaude/skills/lit-pptx/references/data-display-cookbook.md +13 -6
  49. package/plugins/litclaude/skills/lit-pptx/references/density-and-fill.md +164 -0
  50. package/plugins/litclaude/skills/lit-pptx/references/design-system-azure.md +7 -4
  51. package/plugins/litclaude/skills/lit-pptx/references/direction-step.md +186 -0
  52. package/plugins/litclaude/skills/lit-pptx/references/examples/01-pitch-signal-ko.md +210 -0
  53. package/plugins/litclaude/skills/lit-pptx/references/examples/02-pitch-studio-ko.md +147 -0
  54. package/plugins/litclaude/skills/lit-pptx/references/examples/03-business-review-ledger-ko.md +199 -0
  55. package/plugins/litclaude/skills/lit-pptx/references/examples/04-business-review-night-en.md +203 -0
  56. package/plugins/litclaude/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +179 -0
  57. package/plugins/litclaude/skills/lit-pptx/references/examples/06-lecture-chalk-ko.md +199 -0
  58. package/plugins/litclaude/skills/lit-pptx/references/examples/07-data-review-night-en.md +194 -0
  59. package/plugins/litclaude/skills/lit-pptx/references/examples/08-image-led-atlas-ko.md +175 -0
  60. package/plugins/litclaude/skills/lit-pptx/references/examples/09-briefing-gazette-ko.md +204 -0
  61. package/plugins/litclaude/skills/lit-pptx/references/examples/10-status-update-ledger-ko.md +179 -0
  62. package/plugins/litclaude/skills/lit-pptx/references/examples/11-portfolio-studio-en.md +164 -0
  63. package/plugins/litclaude/skills/lit-pptx/references/examples/12-research-tutorial-chalk-en.md +174 -0
  64. package/plugins/litclaude/skills/lit-pptx/references/interview-protocol.md +1 -1
  65. package/plugins/litclaude/skills/lit-pptx/references/layout-families.md +198 -0
  66. package/plugins/litclaude/skills/lit-pptx/references/stages/autopilot.md +1 -1
  67. package/plugins/litclaude/skills/lit-pptx/references/stages/interview.md +2 -2
  68. package/plugins/litclaude/skills/lit-pptx/references/stages/plan.md +1 -1
  69. package/plugins/litclaude/skills/lit-pptx/references/title-treatments.md +109 -0
  70. package/plugins/litclaude/skills/lit-pptx/references/tonalities/atlas.md +139 -0
  71. package/plugins/litclaude/skills/lit-pptx/references/tonalities/chalk.md +148 -0
  72. package/plugins/litclaude/skills/lit-pptx/references/tonalities/gazette.md +149 -0
  73. package/plugins/litclaude/skills/lit-pptx/references/tonalities/ledger.md +149 -0
  74. package/plugins/litclaude/skills/lit-pptx/references/tonalities/night.md +147 -0
  75. package/plugins/litclaude/skills/lit-pptx/references/tonalities/paper.md +151 -0
  76. package/plugins/litclaude/skills/lit-pptx/references/tonalities/signal.md +150 -0
  77. package/plugins/litclaude/skills/lit-pptx/references/tonalities/studio.md +132 -0
  78. package/plugins/litclaude/skills/lit-pptx/scripts/compile-deck.js +64 -16
  79. package/plugins/litclaude/skills/lit-pptx/scripts/craft_extras.py +664 -3
  80. package/plugins/litclaude/skills/lit-pptx/scripts/deck_craft.py +48 -6
  81. package/plugins/litclaude/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  82. package/plugins/litclaude/skills/lit-pptx/scripts/lib/layout-resolver.js +362 -11
  83. package/plugins/litclaude/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  84. package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +211 -65
  85. package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-pack.js +3559 -0
  86. package/plugins/litclaude/skills/lit-pptx/scripts/lib/slide-ast.js +10 -6
  87. package/plugins/litclaude/skills/lit-pptx/scripts/lib/template-registry.js +286 -1
  88. package/plugins/litclaude/skills/lit-pptx/scripts/qa_deck.py +39 -10
  89. package/plugins/litclaude/skills/lit-pptx/scripts/validate_pptx.py +22 -7
  90. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  91. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
  92. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
  93. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
  94. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +92 -0
  95. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +97 -0
  96. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +93 -0
  97. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +94 -0
  98. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/night/pack.yaml +93 -0
  99. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/paper/pack.yaml +98 -0
  100. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/signal/pack.yaml +94 -0
  101. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/studio/pack.yaml +92 -0
@@ -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
  }
@@ -388,7 +388,292 @@ function loadTemplate(name) {
388
388
  template,
389
389
  mapping,
390
390
  capabilities,
391
+ pack: legacyPack(template.name || name, template),
391
392
  };
392
393
  }
393
394
 
394
- module.exports = { loadTemplate, listTemplates };
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-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
+ },
676
+ };
677
+ }
678
+
679
+ module.exports = { loadTemplate, listTemplates, loadPack, listTonalities, loadTonality, hasCapability, validatePack, legacyPack, LEGACY_TOKENS };
@@ -1,7 +1,7 @@
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 — layout_inventory.py (frame overflow / slide overflow / off-slide)
@@ -12,6 +12,13 @@ exits non-zero if ANY real defect is found:
12
12
  5. Deck craft — deck_craft.py (sparse slides, table-only decks, shapes off
13
13
  the canvas, stray empty boxes, [placeholder] blanks, long
14
14
  foreign-language source lines on a Korean deck)
15
+ 6. Craft floor — craft_extras.py (OF-101..OF-109 per slide; OF-110..OF-113
16
+ over the deck: title-treatment count, composition variety,
17
+ empty bottom band, one frame per title treatment; OF-114
18
+ titles and the cover subtitle as labels, not sentences;
19
+ OF-115 no empty region; OF-117 no light figure card on a
20
+ dark ground; OF-116 with --sibling: another tonality of
21
+ the same source draws another skeleton)
15
22
 
16
23
  No LibreOffice / rendering engine required: everything is read from the OOXML
17
24
  via python-pptx. (A renderer is only ever needed for the *visual* pass, which
@@ -107,10 +114,22 @@ def _bbox(shape):
107
114
  return None
108
115
 
109
116
 
110
- def _resolve_bg(shape, idx, filled):
117
+ def _slide_ground(slide) -> tuple[int, int, int]:
118
+ """The slide's own solid background, white when it has none (or inherits one)."""
119
+ try:
120
+ fill = slide.background.fill
121
+ if fill.type == 1: # solid
122
+ return _hex_to_rgb(fill.fore_color.rgb) or (255, 255, 255)
123
+ except (AttributeError, TypeError, ValueError):
124
+ pass
125
+ return (255, 255, 255)
126
+
127
+
128
+ def _resolve_bg(shape, idx, filled, ground=(255, 255, 255)):
111
129
  """Effective background for a text shape: its own fill, else the topmost
112
130
  filled shape drawn *behind* it (lower z-order) that contains its centre,
113
- else white. Prevents false positives like white text over a charcoal band."""
131
+ else the slide background. Prevents false positives like white text over a
132
+ charcoal band, or light text on a dark slide."""
114
133
  own = _shape_fill_rgb(shape)
115
134
  if own is not None:
116
135
  return own
@@ -125,7 +144,7 @@ def _resolve_bg(shape, idx, filled):
125
144
  best = f_rgb # later (higher idx) wins -> topmost behind text
126
145
  if best is not None:
127
146
  return best
128
- return (255, 255, 255)
147
+ return ground
129
148
 
130
149
 
131
150
  def check_contrast(path: Path) -> dict[str, Any]:
@@ -136,10 +155,11 @@ def check_contrast(path: Path) -> dict[str, Any]:
136
155
  for s_idx, slide in enumerate(prs.slides, start=1):
137
156
  shapes = list(slide.shapes)
138
157
  filled = [(i, rgb, _bbox(sh)) for i, sh in enumerate(shapes) if (rgb := _shape_fill_rgb(sh)) is not None]
158
+ ground = _slide_ground(slide)
139
159
  for idx, shape in enumerate(shapes):
140
160
  if not getattr(shape, "has_text_frame", False):
141
161
  continue
142
- bg = _resolve_bg(shape, idx, filled)
162
+ bg = _resolve_bg(shape, idx, filled, ground)
143
163
  for para in shape.text_frame.paragraphs:
144
164
  for run in para.runs:
145
165
  if not run.text.strip():
@@ -457,7 +477,11 @@ def check_evidence_binding(path: Path) -> dict[str, Any]:
457
477
  checked += 1
458
478
  figure_box = _bbox_in(shape)
459
479
  nearby: list[str] = []
460
- if figure_box is not None:
480
+ page_area = (prs.slide_width / 914400.0) * (prs.slide_height / 914400.0)
481
+ if figure_box is not None and _is_full_bleed(figure_box, page_area):
482
+ # A full-bleed picture carries its caption on the panel laid over it.
483
+ nearby = [text for other in shapes if other is not shape and (text := _shape_text(other))]
484
+ elif figure_box is not None:
461
485
  figure_width = figure_box[2] - figure_box[0]
462
486
  for other in shapes:
463
487
  text = _shape_text(other)
@@ -664,14 +688,17 @@ def run_validate(pptx: Path) -> dict[str, Any]:
664
688
 
665
689
 
666
690
  # ── gate ───────────────────────────────────────────────────────────────────
667
- def qa(pptx: Path) -> dict[str, Any]:
691
+ def qa(pptx: Path, sibling: Path | None = None) -> dict[str, Any]:
668
692
  from ooxml_integrity import check_package
669
693
  from deck_craft import analyse as analyse_craft
670
- from craft_extras import analyse as analyse_extras
694
+ from craft_extras import analyse as analyse_extras, compare_skeletons
671
695
 
672
696
  integrity = check_package(pptx)
673
697
  craft = analyse_craft(pptx)
674
698
  extras = analyse_extras(pptx)
699
+ # Another tonality of the same source, when given: the two must not draw one skeleton (OF-116).
700
+ if sibling is not None:
701
+ extras["findings"] += compare_skeletons(pptx, sibling)
675
702
  validate_report = run_validate(pptx)
676
703
  inv_summary = summarize_inventory(load_inventory_issues(pptx))
677
704
  contrast = check_contrast(pptx)
@@ -695,7 +722,8 @@ def qa(pptx: Path) -> dict[str, Any]:
695
722
  extras_high.setdefault(finding["check"], []).append(finding)
696
723
  for check, found in extras_high.items():
697
724
  slides = sorted({f["slide"] for f in found if f["slide"] is not None})
698
- reasons.append(f"craft {check} on slide(s) {', '.join(map(str, slides))} — {found[0]['hint']}")
725
+ where = f" on slide(s) {', '.join(map(str, slides))}" if slides else f" ({found[0]['detail']})"
726
+ reasons.append(f"craft {check}{where} — {found[0]['hint']}")
699
727
  if not validate_report.get("pass", False):
700
728
  reasons.append("anti-slop: " + ", ".join(
701
729
  k for k, v in validate_report.get("checks", {}).items() if isinstance(v, dict) and not v.get("pass", True)
@@ -756,8 +784,9 @@ def qa(pptx: Path) -> dict[str, Any]:
756
784
  def main(argv: list[str] | None = None) -> int:
757
785
  parser = argparse.ArgumentParser(description="Honest renderer-agnostic QA gate for PPTX decks.")
758
786
  parser.add_argument("pptx", type=Path)
787
+ parser.add_argument("--sibling", type=Path, help="the same source compiled under another tonality (OF-116)")
759
788
  args = parser.parse_args(argv)
760
- report = qa(args.pptx.resolve())
789
+ report = qa(args.pptx.resolve(), args.sibling.resolve() if args.sibling else None)
761
790
  print(json.dumps(report, ensure_ascii=False, indent=2))
762
791
  return 0 if report["pass"] else 1
763
792
 
@@ -61,15 +61,24 @@ def _load_terms() -> tuple[list[str], list[str], bool, bool]:
61
61
  )
62
62
 
63
63
 
64
- def _build_regex(terms: list[str], case_insensitive: bool, whole_word: bool) -> re.Pattern[str] | None:
64
+ def _build_regex(terms: list[str], case_insensitive: bool, whole_word: bool, single_words: bool = False) -> re.Pattern[str] | None:
65
65
  if not terms:
66
66
  return None
67
- escaped = [re.escape(term) for term in terms]
67
+ # An acronym (RAM, FTO) is a word in capitals: matched as that word, so "diagram" or "program"
68
+ # does not contain it. With single_words a term that is one Latin word (todo, tbd) is matched as
69
+ # that word or its plural (TODOs), so "Mastodon" or "photodocument" does not contain it. Every
70
+ # other term keeps the file's case and word settings.
71
+ acronyms = [term for term in terms if re.fullmatch(r"[A-Z]{2,5}", term)]
72
+ escaped = [rf"(?<![A-Za-z]){re.escape(term)}s?(?![A-Za-z])" if single_words and re.fullmatch(r"[A-Za-z]+", term) else re.escape(term)
73
+ for term in terms if term not in acronyms]
68
74
  pattern = "|".join(escaped)
69
- if whole_word:
75
+ if whole_word and pattern:
70
76
  pattern = rf"(?:(?<=\W)|^)(?:{pattern})(?:(?=\W)|$)"
71
- flags = re.IGNORECASE if case_insensitive else 0
72
- return re.compile(pattern, flags)
77
+ if case_insensitive and pattern:
78
+ pattern = f"(?i:{pattern})"
79
+ if acronyms:
80
+ pattern = "|".join(filter(None, [pattern, rf"(?<![A-Za-z])(?:{'|'.join(map(re.escape, acronyms))})(?![A-Za-z])"]))
81
+ return re.compile(pattern)
73
82
 
74
83
 
75
84
  def _shape_text(shape) -> str:
@@ -112,6 +121,10 @@ def _slide_has_title(slide, slide_height: int | None) -> bool:
112
121
  title_shape = getattr(slide.shapes, "title", None)
113
122
  if title_shape is not None and _shape_text(title_shape):
114
123
  return True
124
+ # A tonality deck names its title frame by treatment (title@side-rail, title@bottom-anchor, ...),
125
+ # wherever the treatment puts it on the slide.
126
+ if any(str(getattr(shape, "name", "")).startswith("title@") and _shape_text(shape) for shape in slide.shapes):
127
+ return True
115
128
  for shape in slide.shapes:
116
129
  if not getattr(shape, "is_placeholder", False):
117
130
  continue
@@ -137,7 +150,7 @@ def _slide_has_title(slide, slide_height: int | None) -> bool:
137
150
  def lint(path: str | Path) -> dict[str, Any]:
138
151
  prs = Presentation(str(path))
139
152
  hard_terms, soft_terms, case_insensitive, whole_word = _load_terms()
140
- hard_re = _build_regex(hard_terms, case_insensitive, whole_word)
153
+ hard_re = _build_regex(hard_terms, case_insensitive, whole_word, single_words=True)
141
154
  soft_re = _build_regex(soft_terms, case_insensitive, whole_word)
142
155
 
143
156
  missing_titles: list[int] = []
@@ -175,7 +188,9 @@ def lint(path: str | Path) -> dict[str, Any]:
175
188
  default_font_hits.append(f"slide {slide_idx}: {font_name}")
176
189
 
177
190
  last_slide_text = "\n".join(_shape_text(shape) for shape in prs.slides[-1].shapes if getattr(shape, "has_text_frame", False)).lower() if prs.slides else ""
178
- has_source_note = "source:" in last_slide_text or "출처" in last_slide_text
191
+ # A closing references page is the deck's source note in its long form.
192
+ has_source_note = "source:" in last_slide_text or "출처" in last_slide_text or bool(
193
+ re.search(r"^\s*(references|참고\s*문헌)\s*$", last_slide_text, re.MULTILINE))
179
194
 
180
195
  failures = {
181
196
  "missing_titles": bool(missing_titles),
@@ -1,5 +1,5 @@
1
1
  # Layout Mapping — AZURE-PRO (16:9, 13.333 x 7.5 in). Positions in inches.
2
- # render_style: azure → body→cards, kpi-table→KPI cards, cover title **emphasis**.
2
+ # rich-blocks capability (template.yaml pack:) → body→cards, kpi-table→KPI cards, cover title **emphasis**.
3
3
 
4
4
  layouts:
5
5
  cover:
@@ -1,7 +1,6 @@
1
1
  name: AZURE-A2Z
2
2
  version: "1.0.0"
3
3
  label: "Azure Professional (에이투지체 / A2Z)"
4
- render_style: azure
5
4
  description: "Flagship SOTA blue-and-white deck — 16:9 widescreen, Pretendard, geometric gradient accents, rounded tint cards, numbered chips, KPI cards. Pro-designer aesthetic."
6
5
 
7
6
  fonts:
@@ -35,3 +34,7 @@ global_typography:
35
34
  bullet_char: "•"
36
35
  bullet_color: "1D4ED8"
37
36
  section_accent: "1D4ED8"
37
+
38
+ # Read as a legacy pack: what this template can draw beyond plain text regions.
39
+ pack:
40
+ capabilities: [rich-blocks, recolor, font-swap, mesh-background]
@@ -1,5 +1,5 @@
1
1
  # Layout Mapping — AZURE-PRO (16:9, 13.333 x 7.5 in). Positions in inches.
2
- # render_style: azure → body→cards, kpi-table→KPI cards, cover title **emphasis**.
2
+ # rich-blocks capability (template.yaml pack:) → body→cards, kpi-table→KPI cards, cover title **emphasis**.
3
3
 
4
4
  layouts:
5
5
  cover:
@@ -1,7 +1,6 @@
1
1
  name: AZURE-PRO
2
2
  version: "1.0.0"
3
3
  label: "Azure Professional"
4
- render_style: azure
5
4
  description: "Flagship SOTA blue-and-white deck — 16:9 widescreen, Pretendard, geometric gradient accents, rounded tint cards, numbered chips, KPI cards. Pro-designer aesthetic."
6
5
 
7
6
  fonts:
@@ -35,3 +34,7 @@ global_typography:
35
34
  bullet_char: "•"
36
35
  bullet_color: "1D4ED8"
37
36
  section_accent: "1D4ED8"
37
+
38
+ # Read as a legacy pack: what this template can draw beyond plain text regions.
39
+ pack:
40
+ capabilities: [rich-blocks, recolor, font-swap, mesh-background]
@@ -0,0 +1,92 @@
1
+ # Atlas: a picture book with captions. Images bleed, titles sit on dark panels over photographs
2
+ # or under large images, text stays short and calm, and the photographs bring the colour. Wrong
3
+ # when there are few real images: a look built around pictures turns into empty boxes without them.
4
+ id: atlas
5
+ name: Atlas
6
+ version: "1.0.0"
7
+ intent: "Image-led decks: products, portfolios, places, field work."
8
+ canvas: ["16:9", "4:3"]
9
+
10
+ # Pretendard by default; the A2Z map below applies only when the source asks (faces: a2z).
11
+ faces:
12
+ display: Pretendard Regular
13
+ title: Pretendard Bold
14
+ body: Pretendard Regular
15
+ label: Pretendard Regular
16
+ numeral: Pretendard Regular
17
+ faces-a2z:
18
+ display: A2Z Light
19
+ title: Pretendard Bold
20
+ body: Pretendard Regular
21
+ label: Pretendard Regular
22
+ numeral: A2Z Light
23
+
24
+ palette:
25
+ ground: "#FFFFFF"
26
+ surface: "#F3F4F5"
27
+ ink: "#1A1D21"
28
+ ink-muted: "#555B63"
29
+ line: "#D6D9DD"
30
+ accent: "#2B4C7E"
31
+ accent-deep: "#1B2F4E"
32
+ accent-tint: "#E6ECF4"
33
+ field: "#1A1D21"
34
+ on-field: "#FFFFFF"
35
+ positive: "#1E7A3F"
36
+ negative: "#B3261E"
37
+ series: ["#2B4C7E", "#555B63", "#8A6A2E", "#3E7A5A"]
38
+
39
+ ramp: reading
40
+ hero: false
41
+ type-voice:
42
+ title-tracking: 0
43
+ display-tracking: -1
44
+
45
+ density: 10
46
+ variance: 7
47
+ radius: 0
48
+ edge: fill
49
+
50
+ treatments: [overlay, bottom-anchor, side-rail, top-plain-large, statement]
51
+ role-defaults:
52
+ content: side-rail
53
+ data: top-plain-large
54
+ data-takeaway: bottom-anchor
55
+ sequence: top-plain-large
56
+ image: overlay
57
+ statement: statement
58
+ reference: side-rail
59
+ definition: side-rail
60
+
61
+ decoration: [caption-band, hairline-rule, colour-field]
62
+ layout-families: [image-full, image-split, photo-grid, figure-pair, asymmetric-feature, kpi-row, comparison, timeline, text-column, statement]
63
+ # Display slides: the device a type-led cover, a statement, a big number and a closing's next step
64
+ # are drawn with (see references/layout-families.md, "Display slides").
65
+ display:
66
+ cover: plate
67
+ statement: drench
68
+ number: tint
69
+ closing: band
70
+ covers: [cover-full-image, cover-split-image, cover-typographic]
71
+ sections: [section-image, section-field]
72
+ closings: [closing-contact-split, closing-ask]
73
+
74
+ table:
75
+ header: none
76
+ header-ink: ink
77
+ rules: rows
78
+ banding: none
79
+ totals: bold
80
+ chart:
81
+ gridlines: hairline
82
+ labels: direct
83
+ highlight: accent-on-muted
84
+ image:
85
+ frame: none
86
+
87
+ fill-order: [anchor-visual, change-family, distribute, step-up]
88
+ avoid:
89
+ - text placed straight on photographs
90
+ - images in card frames
91
+ - more than three images in one row
92
+ - grey placeholder boxes in a delivered deck