@litfamily/litgrok 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.grok/hooks/auto-handoff-gate.mjs +15 -2
  2. package/.grok/rules/00-litgrok.md +1 -1
  3. package/.grok/skills/frontend-ui-ux/THIRD-PARTY-NOTICE.txt +0 -5
  4. package/.grok/skills/frontend-ui-ux/references/slop-register.md +4 -4
  5. package/.grok/skills/frontend-ui-ux/scripts/import-design-intelligence.mjs +1 -1
  6. package/.grok/skills/lit-docx/SKILL.md +59 -11
  7. package/.grok/skills/lit-docx/examples/01-report-ko.md +137 -0
  8. package/.grok/skills/lit-docx/examples/02-brief-ko.md +82 -0
  9. package/.grok/skills/lit-docx/examples/03-manual-ko.md +118 -0
  10. package/.grok/skills/lit-docx/examples/04-proposal-ko.md +92 -0
  11. package/.grok/skills/lit-docx/examples/05-memo-en.md +42 -0
  12. package/.grok/skills/lit-docx/examples/06-journal-en.md +90 -0
  13. package/.grok/skills/lit-docx/examples/07-report-en.md +103 -0
  14. package/.grok/skills/lit-docx/examples/08-proposal-en.md +90 -0
  15. package/.grok/skills/lit-docx/examples/09-memo-ko.md +41 -0
  16. package/.grok/skills/lit-docx/references/components.md +151 -0
  17. package/.grok/skills/lit-docx/references/direction-step.md +129 -0
  18. package/.grok/skills/lit-docx/references/page-composition.md +129 -0
  19. package/.grok/skills/lit-docx/references/tonalities/brief.md +98 -0
  20. package/.grok/skills/lit-docx/references/tonalities/journal.md +100 -0
  21. package/.grok/skills/lit-docx/references/tonalities/manual.md +107 -0
  22. package/.grok/skills/lit-docx/references/tonalities/memo.md +95 -0
  23. package/.grok/skills/lit-docx/references/tonalities/proposal.md +105 -0
  24. package/.grok/skills/lit-docx/references/tonalities/report.md +114 -0
  25. package/.grok/skills/lit-docx/scripts/convert_md_to_docx.py +394 -51
  26. package/.grok/skills/lit-docx/scripts/docx_design.py +2446 -0
  27. package/.grok/skills/lit-docx/scripts/docx_gate.py +203 -0
  28. package/.grok/skills/lit-docx/scripts/docx_layout.py +959 -0
  29. package/.grok/skills/lit-docx/scripts/run.mjs +3 -2
  30. package/.grok/skills/lit-docx/scripts/slop_lint.py +63 -17
  31. package/.grok/skills/lit-docx/templates/tonalities/brief.yaml +29 -0
  32. package/.grok/skills/lit-docx/templates/tonalities/journal.yaml +26 -0
  33. package/.grok/skills/lit-docx/templates/tonalities/manual.yaml +27 -0
  34. package/.grok/skills/lit-docx/templates/tonalities/memo.yaml +27 -0
  35. package/.grok/skills/lit-docx/templates/tonalities/proposal.yaml +27 -0
  36. package/.grok/skills/lit-docx/templates/tonalities/report.yaml +27 -0
  37. package/.grok/skills/lit-pptx/NOTICE.md +1 -1
  38. package/.grok/skills/lit-pptx/SKILL.md +70 -14
  39. package/.grok/skills/lit-pptx/agents/analyst.md +1 -1
  40. package/.grok/skills/lit-pptx/agents/thesis.md +2 -2
  41. package/.grok/skills/lit-pptx/examples/01-pitch-signal-ko.md +175 -0
  42. package/.grok/skills/lit-pptx/examples/02-business-review-ledger-ko.md +199 -0
  43. package/.grok/skills/lit-pptx/examples/03-research-talk-paper-en.md +177 -0
  44. package/.grok/skills/lit-pptx/examples/04-lecture-chalk-ko.md +176 -0
  45. package/.grok/skills/lit-pptx/examples/05-data-review-night-en.md +221 -0
  46. package/.grok/skills/lit-pptx/examples/06-image-led-atlas-en.md +143 -0
  47. package/.grok/skills/lit-pptx/examples/07-briefing-gazette-ko.md +192 -0
  48. package/.grok/skills/lit-pptx/examples/08-status-update-ledger-en.md +168 -0
  49. package/.grok/skills/lit-pptx/examples/09-pitch-studio-en.md +157 -0
  50. package/.grok/skills/lit-pptx/examples/10-image-led-studio-ko.md +175 -0
  51. package/.grok/skills/lit-pptx/examples/11-business-review-night-en.md +181 -0
  52. package/.grok/skills/lit-pptx/examples/12-status-update-gazette-ko.md +199 -0
  53. package/.grok/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
  54. package/.grok/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
  55. package/.grok/skills/lit-pptx/references/density-and-fill.md +155 -0
  56. package/.grok/skills/lit-pptx/references/direction-step.md +211 -0
  57. package/.grok/skills/lit-pptx/references/layout-families.md +193 -0
  58. package/.grok/skills/lit-pptx/references/title-treatments.md +134 -0
  59. package/.grok/skills/lit-pptx/references/tonalities/atlas.md +124 -0
  60. package/.grok/skills/lit-pptx/references/tonalities/chalk.md +135 -0
  61. package/.grok/skills/lit-pptx/references/tonalities/gazette.md +127 -0
  62. package/.grok/skills/lit-pptx/references/tonalities/ledger.md +134 -0
  63. package/.grok/skills/lit-pptx/references/tonalities/night.md +132 -0
  64. package/.grok/skills/lit-pptx/references/tonalities/paper.md +127 -0
  65. package/.grok/skills/lit-pptx/references/tonalities/signal.md +118 -0
  66. package/.grok/skills/lit-pptx/references/tonalities/studio.md +123 -0
  67. package/.grok/skills/lit-pptx/scripts/compile-deck.js +65 -17
  68. package/.grok/skills/lit-pptx/scripts/craft_extras.py +82 -18
  69. package/.grok/skills/lit-pptx/scripts/deck_output.py +598 -0
  70. package/.grok/skills/lit-pptx/scripts/embed_fonts.py +1 -1
  71. package/.grok/skills/lit-pptx/scripts/inventory.py +6 -2
  72. package/.grok/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
  73. package/.grok/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  74. package/.grok/skills/lit-pptx/scripts/lib/layout-resolver.js +401 -16
  75. package/.grok/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  76. package/.grok/skills/lit-pptx/scripts/lib/render-adapter-html.js +2 -2
  77. package/.grok/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -126
  78. package/.grok/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
  79. package/.grok/skills/lit-pptx/scripts/lib/slide-ast.js +54 -6
  80. package/.grok/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
  81. package/.grok/skills/lit-pptx/scripts/qa_deck.py +43 -10
  82. package/.grok/skills/lit-pptx/scripts/run.mjs +3 -2
  83. package/.grok/skills/lit-pptx/scripts/validate_pptx.py +17 -5
  84. package/.grok/skills/lit-pptx/templates/boilerplate_professional.css +6 -6
  85. package/.grok/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  86. package/.grok/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
  87. package/.grok/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
  88. package/.grok/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
  89. package/.grok/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +99 -0
  90. package/.grok/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +104 -0
  91. package/.grok/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +99 -0
  92. package/.grok/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +100 -0
  93. package/.grok/skills/lit-pptx/templates/tonalities/night/pack.yaml +100 -0
  94. package/.grok/skills/lit-pptx/templates/tonalities/paper/pack.yaml +105 -0
  95. package/.grok/skills/lit-pptx/templates/tonalities/signal/pack.yaml +101 -0
  96. package/.grok/skills/lit-pptx/templates/tonalities/studio/pack.yaml +99 -0
  97. package/.grok/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
  98. package/.grok/skills/lit-typographic-motion/fonts/pins.json +6 -6
  99. package/.grok/skills/lit-typographic-motion/references/runtime.md +1 -1
  100. package/.grok/skills/lit-typographic-motion/references/style-bible.md +2 -2
  101. package/.grok/skills/lit-typographic-motion/references/type-craft.md +2 -2
  102. package/.grok/skills/lit-typographic-motion/scripts/lib/gate.mjs +1 -1
  103. package/.grok/skills/litgrok/SKILL.md +1 -1
  104. package/.grok-plugin/plugin.json +1 -1
  105. package/CHANGELOG.md +11 -0
  106. package/README.md +9 -9
  107. package/README_ko-KR.md +9 -9
  108. package/docs/assets/readme/badge-version.svg +1 -1
  109. package/docs/reference.md +1 -1
  110. package/docs/reference_ko-KR.md +1 -1
  111. package/package.json +1 -1
  112. package/plugin.json +1 -1
  113. package/.grok/skills/lit-pptx/pretendard-font/public/static/PretendardGOV-Bold.otf +0 -0
  114. package/.grok/skills/lit-pptx/pretendard-font/public/static/PretendardGOV-Regular.otf +0 -0
@@ -246,21 +246,25 @@ function blockToRegion(blockType, block, layout) {
246
246
  * much vertical room the block needs, so the rule lives here where the geometry
247
247
  * is worked out and the renderer reads it from here.
248
248
  */
249
- function looksLikeKpi(content) {
249
+ function looksLikeKpi(content, o = {}) {
250
+ if (content && content.chart) return false;
250
251
  const headers = (content && content.headers) || [];
251
252
  const rows = (content && content.rows) || [];
252
253
  const cols = headers.length;
253
- if (cols < 2 || cols > 4) return false;
254
+ // A tonality row may carry six figures; the legacy cards stop at four.
255
+ if (cols < 2 || cols > (o.basis ? 6 : 4)) return false;
254
256
  if (cols === 2) {
255
257
  const pairs = [headers, ...rows];
256
258
  return pairs.length <= 4 && pairs.every((p) => String(p[0]).length <= 12);
257
259
  }
260
+ // Under a tonality a second row may state each figure's basis or comparison.
261
+ if (o.basis && rows.length === 2) return rows[0].every((c) => String(c).length <= 12) && rows[1].every((c) => String(c).length <= 24);
258
262
  return rows.length === 1 && rows[0].every((c) => String(c).length <= 12);
259
263
  }
260
264
 
261
- // A KPI badge stacks a small label over a large value, so it stands roughly
262
- // this tall no matter how few rows fed it.
263
- const KPI_BLOCK_HEIGHT_IN = 1.75;
265
+ // A KPI badge stacks a small label over a large value, so it stands roughly as tall as the
266
+ // template's kpi.blockHeight token (1.75in on the legacy templates) no matter how few rows fed it.
267
+ const grid = require("./grid-resolver");
264
268
 
265
269
  // ---------------------------------------------------------------------------
266
270
  // Capability validation
@@ -273,7 +277,7 @@ const KPI_BLOCK_HEIGHT_IN = 1.75;
273
277
  */
274
278
  const CAPABILITY_RULES = {
275
279
  cover: new Set(["title", "notes"]),
276
- section: new Set(["title", "notes"]),
280
+ section: new Set(["title", "notes", "image", "figure-caption"]),
277
281
  content: new Set(["title", "key-message", "body", "image", "notes", "kpi-table", "figure-caption", "table-caption"]),
278
282
  main: new Set(["title", "key-message", "body", "main-box", "kpi-table", "image", "notes", "figure-caption", "table-caption"]),
279
283
  summary: new Set(["title", "key-message", "summary-group", "image", "kpi-table", "figure-caption", "table-caption"]),
@@ -433,6 +437,33 @@ function resolveColumns(slideIndex, layout, regionDefs, block) {
433
437
  * }
434
438
  * @returns {object} — Resolved deck spec with positioned regions
435
439
  */
440
+ /**
441
+ * Caption prefixes in the deck's language: "도"/"표" for a Korean deck, "Figure"/
442
+ * "Table" otherwise. Frontmatter `lang: ko|en` decides; without it, a deck whose
443
+ * text is at least a tenth Hangul (one syllable weighs about two Latin letters)
444
+ * is Korean. English decks used to get Korean prefixes.
445
+ */
446
+ function captionPrefixes(ast) {
447
+ const meta = (ast.deck && ast.deck.metadata) || {};
448
+ const lang = String(meta.lang || meta.language || "").toLowerCase();
449
+ let korean;
450
+ if (lang) korean = lang.startsWith("ko");
451
+ else {
452
+ const strings = [];
453
+ const walk = (node, key) => {
454
+ if (typeof node === "string") { if (!["type", "layout", "src", "variant", "kind", "align"].includes(key)) strings.push(node); }
455
+ else if (Array.isArray(node)) node.forEach((n) => walk(n, key));
456
+ else if (node && typeof node === "object") for (const [k, v] of Object.entries(node)) walk(v, k);
457
+ };
458
+ walk(ast.slides || [], "");
459
+ const text = strings.join(" ");
460
+ const hangul = (text.match(/[\uac00-\ud7a3]/gu) || []).length;
461
+ const latin = (text.match(/[A-Za-z]/gu) || []).length;
462
+ korean = hangul * 2 >= 0.1 * (hangul * 2 + latin) && hangul > 0;
463
+ }
464
+ return korean ? ["도", "표"] : ["Figure", "Table"];
465
+ }
466
+
436
467
  function resolve(ast, templateObj) {
437
468
  if (!ast || !ast.slides) {
438
469
  throw new Error("resolve(): invalid AST — expected an object with a slides array");
@@ -440,6 +471,7 @@ function resolve(ast, templateObj) {
440
471
  if (!templateObj) {
441
472
  throw new Error("resolve(): templateObj is required");
442
473
  }
474
+ if (templateObj.pack && !templateObj.pack.legacy) return resolvePack(ast, templateObj);
443
475
 
444
476
  // Parse YAML strings from template object
445
477
  const mapping = typeof templateObj.mapping === "string"
@@ -460,20 +492,34 @@ function resolve(ast, templateObj) {
460
492
  let tableCount = 0;
461
493
 
462
494
  const deckMeta = ast.deck || {};
495
+ const [FIGURE, TABLE] = captionPrefixes(ast);
463
496
 
497
+ const tokens = (templateObj.pack && templateObj.pack.tokens) || {};
498
+ const kpiBlockHeight = (tokens.kpi && tokens.kpi.blockHeight) || 1.75;
464
499
  for (const slide of ast.slides) {
465
- const resolved = resolveSlide(slide, mapping, capabilities, deckMeta);
500
+ if (slide.meta && slide.meta.title) {
501
+ throw new Error(
502
+ `Slide ${slide.index + 1}: "title: ${slide.meta.title}" picks a title treatment, which needs a tonality; ` +
503
+ `${templateObj.name || "this template"} is a legacy template with one title look`
504
+ );
505
+ }
506
+ const resolved = resolveSlide(slide, mapping, capabilities, deckMeta, kpiBlockHeight);
466
507
  // Auto-number captions across the deck
467
508
  for (const [name, region] of Object.entries(resolved.regions || {})) {
468
509
  if (region.content && region.content.type === "figure-caption") {
469
510
  figureCount++;
470
511
  region.content.number = figureCount;
471
- region.content.prefix = "도";
512
+ region.content.prefix = FIGURE;
472
513
  }
473
- if (region.content && region.content.type === "table-caption") {
514
+ if (region.content && region.content.type === "table-caption" && region.content.figure) {
515
+ // A chart's caption is a figure caption, numbered with the figures.
516
+ figureCount++;
517
+ region.content.number = figureCount;
518
+ region.content.prefix = FIGURE;
519
+ } else if (region.content && region.content.type === "table-caption") {
474
520
  tableCount++;
475
521
  region.content.number = tableCount;
476
- region.content.prefix = "표";
522
+ region.content.prefix = TABLE;
477
523
  }
478
524
  }
479
525
  resolvedSlides.push(resolved);
@@ -493,10 +539,13 @@ function resolve(ast, templateObj) {
493
539
  * @param {object} [capabilities] — Parsed capabilities.yaml
494
540
  * @returns {object} — Resolved slide spec
495
541
  */
496
- function resolveSlide(slide, mapping, capabilities, deckMeta) {
497
- const { index, layout, blocks } = slide;
542
+ function resolveSlide(slide, mapping, capabilities, deckMeta, kpiBlockHeight) {
543
+ const { index, blocks } = slide;
498
544
 
499
- // 1. Look up layout configuration
545
+ // 1. Look up layout configuration. A layout family the template does not declare (kpi-row,
546
+ // chart-insight, …) is drawn on the template layout that hosts its kind of slide.
547
+ const family = familyOf(slide.layout, mapping, index);
548
+ const layout = (mapping.layouts[slide.layout] || slide.layout === "free") ? slide.layout : family.host;
500
549
  const layoutConfig = mapping.layouts[layout] ||
501
550
  (layout === "free" ? FREE_LAYOUT : null);
502
551
  if (!layoutConfig) {
@@ -655,7 +704,7 @@ function resolveSlide(slide, mapping, capabilities, deckMeta) {
655
704
  // lands on top of them.
656
705
  const tableContent = tableRegion.content || {};
657
706
  const needed = looksLikeKpi(tableContent)
658
- ? KPI_BLOCK_HEIGHT_IN
707
+ ? kpiBlockHeight
659
708
  : rowCount * rowH;
660
709
  const tableBottom = tblY + Math.max(needed, tableRegion.h || 0) + 0.3;
661
710
  if ((regions.body.y || 1.044) < tableBottom) {
@@ -699,6 +748,7 @@ function resolveSlide(slide, mapping, capabilities, deckMeta) {
699
748
  return {
700
749
  index,
701
750
  layout,
751
+ family: family.id,
702
752
  layoutSource: layoutConfig.layout_source,
703
753
  decorations,
704
754
  regions,
@@ -719,14 +769,349 @@ function extractContent(block) {
719
769
  if (block.type === "key-message") return block.content || "";
720
770
  if (block.type === "notes") return block.content || "";
721
771
  if (block.type === "body") return { type: "body", items: block.items || [] };
722
- if (block.type === "kpi-table") return { type: "kpi-table", headers: block.headers || [], rows: block.rows || [], caption: block.caption || "" };
772
+ if (block.type === "kpi-table") return { type: "kpi-table", headers: block.headers || [], rows: block.rows || [], caption: block.caption || "", ...(block.chart ? { chart: block.chart } : {}) };
723
773
  if (block.type === "image") return { type: "image", src: block.src || "", caption: block.caption || "" };
724
774
  if (block.type === "figure-caption") return { type: "figure-caption", caption: block.caption || "" };
725
- if (block.type === "table-caption") return { type: "table-caption", caption: block.caption || "" };
775
+ if (block.type === "table-caption") return { type: "table-caption", caption: block.caption || "", ...(block.figure ? { figure: true } : {}) };
726
776
  if (block.type === "summary-group") return { type: "summary-group", heading: block.heading, items: block.items || [] };
727
777
  return "";
728
778
  }
729
779
 
780
+ // ---------------------------------------------------------------------------
781
+ // Layout families
782
+ // ---------------------------------------------------------------------------
783
+
784
+ /**
785
+ * The family a slide is stamped with, and the template layout that hosts it when the template
786
+ * does not declare the name itself. A name that is neither keeps the template's own error.
787
+ */
788
+ function familyOf(layout, mapping, index) {
789
+ if (grid.LEGACY_LAYOUTS[layout]) return { id: grid.LEGACY_LAYOUTS[layout], host: layout };
790
+ if (mapping.layouts[layout]) return { id: layout, host: layout };
791
+ try {
792
+ const f = grid.resolveFamily(layout, index + 1);
793
+ return { id: f.family || layout, host: f.kind === "cover" || f.kind === "section" ? f.kind : "content" };
794
+ } catch (_) {
795
+ return { id: layout, host: layout };
796
+ }
797
+ }
798
+
799
+ // ---------------------------------------------------------------------------
800
+ // Tonality packs: regions on the 12-column grid
801
+ // ---------------------------------------------------------------------------
802
+
803
+ const PACK_BLOCKS = {
804
+ cover: new Set(["title", "notes"]),
805
+ section: new Set(["title", "notes", "image", "figure-caption"]),
806
+ content: new Set(["title", "key-message", "body", "main-box", "kpi-table", "image", "notes", "figure-caption", "table-caption", "summary-group"]),
807
+ };
808
+
809
+ const plain = (text) => String(text || "").replace(/\*\*/g, "").trim();
810
+
811
+ /** What a slide holds, as the treatment rules need it. */
812
+ function slideFacts(blocks) {
813
+ const all = blocks.flatMap((b) => (b.type === "columns" ? b.columns.flatMap((c) => c.blocks || []) : [b]));
814
+ const has = (type) => all.some((b) => b.type === type);
815
+ const textBlocks = all.filter((b) => ["body", "summary-group", "main-box", "key-message"].includes(b.type)).length +
816
+ (blocks.some((b) => b.type === "columns") ? 1 : 0);
817
+ const bodyItems = all.filter((b) => b.type === "body").reduce((n, b) => n + (b.items || []).length, 0);
818
+ return {
819
+ image: has("image"),
820
+ visual: has("image") || has("kpi-table"),
821
+ textBlocks: textBlocks + (bodyItems > 1 ? 1 : 0),
822
+ };
823
+ }
824
+
825
+ /** Comparison halves of numbers are data; halves of arguments are content. */
826
+ function comparisonRole(blocks) {
827
+ const text = JSON.stringify(blocks);
828
+ const digits = (text.match(/\d/g) || []).length;
829
+ return digits / Math.max(1, plain(text).length) >= 0.04 ? "data" : "content";
830
+ }
831
+
832
+ /** The ordered content of a pack slide, captions bound to the visual before them. */
833
+ // Families whose geometry is a chart: a table of numbers there is drawn as one.
834
+ const CHART_FAMILIES = new Set(["chart-insight", "full-chart", "kpi-over-chart", "dashboard-grid"]);
835
+ const NUMBER_CELL = /^[+\-−±▲▼△▽]?\s*[\d.,]+\s*(?:%p?|배|x|pt|bp|[가-힣]{1,3}|[A-Za-z]{1,3})?$/u;
836
+
837
+ /** A table whose data columns are all numbers, over three or more rows, can be drawn as a chart. */
838
+ function chartable(c) {
839
+ const rows = c.rows || [];
840
+ const headers = c.headers || [];
841
+ return headers.length >= 2 && rows.length >= 3 &&
842
+ headers.slice(1).every((_, ci) => rows.every((r) => r[ci + 1] != null && NUMBER_CELL.test(plain(r[ci + 1]))));
843
+ }
844
+
845
+ function packItems(blocks, numbering, charts = false) {
846
+ const items = [];
847
+ for (const block of blocks) {
848
+ if (["title", "notes", "key-message", "box", "shape"].includes(block.type)) continue;
849
+ if (block.type === "columns") {
850
+ items.push({ type: "columns", tracks: block.tracks, columns: block.columns.map((c) => packItems(c.blocks || [], numbering, charts)) });
851
+ continue;
852
+ }
853
+ const content = extractContent(block);
854
+ if (charts && block.type === "kpi-table" && !content.chart && chartable(content)) {
855
+ content.chart = { type: (content.rows || []).length > 8 ? "line" : "column" };
856
+ }
857
+ if (block.type === "figure-caption" || block.type === "table-caption") {
858
+ // A table drawn as a chart is captioned as a figure.
859
+ const charted = [...items].reverse().find((i) => i.type === "kpi-table");
860
+ const figure = block.type === "figure-caption" || block.figure || Boolean(charted && charted.content.chart);
861
+ content.number = figure ? ++numbering.figure : ++numbering.table;
862
+ content.prefix = figure ? numbering.FIGURE : numbering.TABLE;
863
+ const target = [...items].reverse().find((i) => ["kpi-table", "image"].includes(i.type));
864
+ if (target && !target.caption) target.caption = content;
865
+ else items.push({ type: "caption", content });
866
+ continue;
867
+ }
868
+ items.push({ type: block.type, content });
869
+ }
870
+ return items;
871
+ }
872
+
873
+ /**
874
+ * What covers and sections can draw from the deck itself: the first image, the part names (from
875
+ * the agenda heads, else the section titles), the first row of headline numbers and a real year.
876
+ */
877
+ function deckFacts(ast) {
878
+ const facts = { image: null, index: [], figures: [], numeral: null };
879
+ const sections = [];
880
+ for (const slide of ast.slides) {
881
+ for (const b of slide.blocks) {
882
+ if (!facts.image && b.type === "image" && b.src) facts.image = { src: b.src, caption: b.caption };
883
+ if (!facts.figures.length && b.type === "kpi-table" && !b.chart && looksLikeKpi(b, { basis: true })) {
884
+ const headers = b.headers || [];
885
+ const row = (b.rows || [])[0] || [];
886
+ facts.figures = headers.slice(0, 4).map((h, i) => ({ label: plain(h), value: plain(row[i]) })).filter((f) => f.value);
887
+ }
888
+ }
889
+ if (slide.layout === "agenda" && !facts.index.length) {
890
+ const body = slide.blocks.find((b) => b.type === "body");
891
+ const items = ((body && body.items) || []).filter((i) => plain(i.heading || i.text)).slice(0, 6);
892
+ facts.index = items.map((i) => plain(i.heading || String(i.text || "").replace(/^\*\*(.+?)\*\*.*$/u, "$1")));
893
+ // What each part is about, so a section titled with an entry's description finds its place too.
894
+ facts.indexAbout = items.map((i) => plain(String(i.text || "").replace(/^\*\*(.+?)\*\*/u, "")));
895
+ }
896
+ if (/^section/u.test(slide.layout)) {
897
+ const t = slide.blocks.find((b) => b.type === "title");
898
+ if (t) sections.push(plain(t.content));
899
+ }
900
+ }
901
+ if (!facts.index.length && sections.length >= 2) facts.index = sections.slice(0, 6);
902
+ facts.indexAbout = facts.indexAbout || [];
903
+ const meta = (ast.deck && ast.deck.metadata) || {};
904
+ const year = `${ast.deck && ast.deck.title ? ast.deck.title : ""} ${meta.date || ""}`.match(/\b(19|20)\d{2}\b/u);
905
+ facts.numeral = year ? year[0] : null;
906
+ return facts;
907
+ }
908
+
909
+ /** Titles of the content slides a section opens, up to the next section or closing (at most three). */
910
+ function sectionContents(ast, from) {
911
+ const out = [];
912
+ for (const s of ast.slides.slice(from + 1)) {
913
+ if (/^(section|closing)/u.test(s.layout || "")) break;
914
+ const t = s.blocks.find((b) => b.type === "title");
915
+ if (t && plain(t.content)) out.push(plain(t.content));
916
+ }
917
+ return out.slice(0, 3);
918
+ }
919
+
920
+ /**
921
+ * One preview line. A bold run-in label keeps its weight and takes a colon after it
922
+ * ("**결과:** 공부 시간이 …") unless it already ends in a separator; any other line is plain text.
923
+ */
924
+ function runIn(text) {
925
+ const m = /^\*\*(.+?)\*\*\s*(.*)$/u.exec(String(text || "").trim());
926
+ if (!m || !plain(m[2])) return plain(text);
927
+ const label = plain(m[1]);
928
+ return `**${/[::.)\]–—-]$/u.test(label) ? label : `${label}:`}** ${plain(m[2])}`;
929
+ }
930
+
931
+ /** The first body lines (bullets or text, up to five) of the slides a section opens, by title. */
932
+ function sectionDigest(ast, from) {
933
+ const out = [];
934
+ for (const s of ast.slides.slice(from + 1)) {
935
+ if (/^(section|closing)/u.test(s.layout || "")) break;
936
+ const t = s.blocks.find((b) => b.type === "title");
937
+ if (!t || !plain(t.content)) continue;
938
+ const lines = s.blocks.filter((b) => b.type === "body").flatMap((b) => b.items || [])
939
+ .filter((i) => ["bullet", "text", "numbered"].includes(i.type) && plain(i.text)).slice(0, 5).map((i) => runIn(i.text));
940
+ out.push({ title: plain(t.content), lines });
941
+ }
942
+ return out.slice(0, 3);
943
+ }
944
+
945
+ /** Position of a section title in the deck's part index (by entry or by its description), or -1. */
946
+ function indexOf(facts, title) {
947
+ const t = String(title || "").trim();
948
+ if (!t || facts.index.length < 2) return -1;
949
+ const at = facts.index.findIndex((name) => name === t);
950
+ return at >= 0 ? at : facts.indexAbout.findIndex((about) => about === t);
951
+ }
952
+
953
+ /** Can this cover, section or closing variant be drawn with what the deck and slide hold? true or the reason. */
954
+ function variantApplies(variant, facts, slide) {
955
+ const ownImage = slide.blocks.find((b) => b.type === "image");
956
+ if (["cover-split-image", "cover-full-image"].includes(variant) && !facts.image) return "the deck has no image";
957
+ if (variant === "section-image" && !ownImage) return "the section slide has no image";
958
+ if (variant === "cover-numeral" && !facts.numeral) return "the deck names no year or real figure";
959
+ if (variant === "cover-index" && facts.index.length < 2) return "the deck has no agenda or sections to list";
960
+ if (variant === "cover-figures" && facts.figures.length < 2) return "the deck has no row of headline numbers";
961
+ if (variant === "closing-statement" && slide.blocks.some((b) => ["kpi-table", "image"].includes(b.type))) return "a statement closing has no table or image";
962
+ return true;
963
+ }
964
+
965
+ /**
966
+ * The variant of a cover, section or closing: the one the source names when the pack lists it and
967
+ * it can be drawn, else the pack's first that can. A generic name (cover, section, closing) asks
968
+ * for the pack's own first choice.
969
+ */
970
+ function chooseVariant(kind, named, pack, facts, slide, notes) {
971
+ const list = pack[kind === "cover" ? "covers" : kind === "section" ? "sections" : "closings"] || [];
972
+ const n = slide.index + 1;
973
+ if (named && list.includes(named)) {
974
+ const why = variantApplies(named, facts, slide);
975
+ if (why === true) return named;
976
+ notes.push(`slide ${n}: ${named} cannot be drawn (${why})`);
977
+ } else if (named && !["cover", "section", "closing"].includes(named)) {
978
+ notes.push(`slide ${n}: ${named} is not a ${pack.id} ${kind} variant (${list.join(", ")})`);
979
+ }
980
+ const pick = list.find((v) => v !== named && variantApplies(v, facts, slide) === true);
981
+ if (!pick) throw new Error(`Slide ${n}: no ${pack.id} ${kind} variant (${list.join(", ")}) can be drawn here`);
982
+ if (named) notes.push(`slide ${n}: drawn as ${pick}`);
983
+ return pick;
984
+ }
985
+
986
+ /**
987
+ * Resolve every slide of a deck against a tonality pack: its family, the title treatments it may
988
+ * take (the pack's role default first, then the others the family allows; the renderer picks among
989
+ * them by fill and the variance dial), the treatment geometry inputs and its content in reading
990
+ * order. Notes go to the build log.
991
+ */
992
+ function resolvePack(ast, templateObj) {
993
+ const pack = templateObj.pack;
994
+ const sizes = pack.tok.sizes;
995
+ const decoration = new Set(pack.decoration || []);
996
+ const notes = [];
997
+ const [FIGURE, TABLE] = captionPrefixes(ast);
998
+ const numbering = { figure: 0, table: 0, FIGURE, TABLE };
999
+ const facts = deckFacts(ast);
1000
+ const families = pack["layout-families"] || [];
1001
+ const wide = { title: grid.faceWidth(pack.faces.title), display: grid.faceWidth(pack.faces.display) };
1002
+ let part = 0;
1003
+
1004
+ const slides = ast.slides.map((slide) => {
1005
+ const n = slide.index + 1;
1006
+ const meta = slide.meta || {};
1007
+ const fam = grid.resolveFamily(slide.layout, n);
1008
+ const kind = fam.kind === "free" ? "content" : fam.kind;
1009
+ const allowed = PACK_BLOCKS[kind];
1010
+ for (const block of slide.blocks) {
1011
+ if (["box", "shape", "columns"].includes(block.type) || allowed.has(block.type)) continue;
1012
+ throw new Error(`Slide ${n} (layout: ${slide.layout}): block type "${block.type}" is not allowed on a ${kind} slide. ` +
1013
+ `Allowed: ${[...allowed].join(", ")}`);
1014
+ }
1015
+ const titleBlock = slide.blocks.find((b) => b.type === "title");
1016
+ const title = titleBlock ? titleBlock.content : "";
1017
+ const lead = slide.blocks.find((b) => b.type === "key-message");
1018
+ const placements = slide.blocks
1019
+ .filter((b) => b.type === "box" || b.type === "shape")
1020
+ .map(({ type, ...rest }) => ({ kind: type === "shape" ? "shape" : "box", ...rest }));
1021
+ const base = { index: slide.index, layout: slide.layout, kind, title, lead: lead ? lead.content : null, placements, blocks: slide.blocks, meta, regions: {} };
1022
+
1023
+ if (kind === "cover" || kind === "section") {
1024
+ if (meta.title) throw new Error(`Slide ${n}: a ${kind} slide takes its variant's title, not "title: ${meta.title}"`);
1025
+ const variant = chooseVariant(kind, fam.family || fam.legacyLayout, pack, facts, slide, notes);
1026
+ let parts = null;
1027
+ if (kind === "section") {
1028
+ // A section numbers itself by its place in the deck's agenda when its title is an agenda
1029
+ // entry (or that entry's description); otherwise it counts the sections so far.
1030
+ const at = indexOf(facts, plain(title));
1031
+ part = at >= 0 ? at + 1 : part + 1;
1032
+ parts = at >= 0 ? facts.index.length : null;
1033
+ }
1034
+ const ownImage = slide.blocks.find((b) => b.type === "image");
1035
+ const cover = { ...facts, image: kind === "section" && ownImage ? { src: ownImage.src, caption: ownImage.caption } : facts.image };
1036
+ const contents = kind === "section" ? sectionContents(ast, slide.index) : [];
1037
+ const digest = kind === "section" ? sectionDigest(ast, slide.index) : [];
1038
+ return { ...base, family: variant, variant, part, parts, cover, contents, digest, regions: { title: { content: title } } };
1039
+ }
1040
+
1041
+ let family = fam.family === "free" ? "text-column" : fam.family;
1042
+ let role = family === "comparison" ? comparisonRole(slide.blocks) : fam.role;
1043
+ if (grid.VARIANTS.closing.includes(family) || fam.legacyLayout === "closing") {
1044
+ family = chooseVariant("closing", fam.legacyLayout === "closing" ? "closing" : family, pack, facts, slide, notes);
1045
+ role = family === "closing-statement" ? "statement" : "content";
1046
+ } else if (!families.includes(family)) {
1047
+ notes.push(`slide ${n}: ${family} is not a ${pack.id} layout family (${families.join(", ")}); drawn with its own geometry`);
1048
+ }
1049
+ let titleOnly = null;
1050
+ if (family === "statement" && !pack.treatments.includes("statement")) {
1051
+ // A pack without a statement title sets the sentence as a top title, the largest it has.
1052
+ titleOnly = ["top-plain-large", "band", "top-rule"].find((t) => pack.treatments.includes(t));
1053
+ if (!titleOnly) throw new Error(`Slide ${n}: ${pack.id} has no statement or top title to set a statement slide with; choose another tonality`);
1054
+ notes.push(`slide ${n}: ${pack.id} has no statement title, so the sentence is drawn as a ${titleOnly} title`);
1055
+ family = "text-column";
1056
+ role = "content";
1057
+ }
1058
+ const items = packItems(slide.blocks, numbering, CHART_FAMILIES.has(family));
1059
+ const paras = slide.blocks.filter((b) => b.type === "body").flatMap((b) => b.items || []);
1060
+ // A closing is not part of a section, so it never carries the part number.
1061
+ const closing = grid.VARIANTS.closing.includes(family);
1062
+ // An agenda numbers its own rows: a count beside its title reads as a stray section number.
1063
+ const numeral = family === "agenda" ? null : part > 0 && !closing ? part : null;
1064
+ const statementCols = (pack.display || {}).statement === "offset" ? [5, 12] : null;
1065
+ const ctx = { grid: pack.grid, sizes, title: plain(title), hero: pack.hero, decoration, numeral, rail: pack.rail, wide, statementCols, tight: Boolean((pack.tok || {}).tight) };
1066
+ const facts1 = slideFacts(slide.blocks);
1067
+ // A quote under a statement title says the quote; the source line becomes the support line.
1068
+ let statementText = null;
1069
+ let supportText = null;
1070
+ if (family === "quote") {
1071
+ const texts = paras.filter((i) => i.type === "text").map((i) => plain(i.text));
1072
+ const by = texts.find((t) => /^[—–-]\s*/u.test(t));
1073
+ statementText = texts.filter((t) => t !== by).join(" ") || null;
1074
+ supportText = by || null;
1075
+ }
1076
+ const applicable = (t) => {
1077
+ const c = t === "statement" && statementText ? { ...ctx, title: statementText } : ctx;
1078
+ const f = t === "statement" && family === "quote" ? { ...facts1, textBlocks: 1 } : facts1;
1079
+ return grid.treatmentApplies(t, f, c);
1080
+ };
1081
+ let allow = grid.familyAllows(family);
1082
+ let treatment;
1083
+ if (titleOnly) {
1084
+ treatment = titleOnly;
1085
+ } else if (!families.includes(family) && !meta.title && !pack.treatments.some((t) => allow.includes(t) && applicable(t) === true)) {
1086
+ // A family from outside the pack that takes none of its titles gets the pack's title for its role.
1087
+ allow = [pack["role-defaults"][role] || pack.treatments[0], ...grid.familyAllows("text-column")];
1088
+ treatment = pack.treatments.find((t) => allow.includes(t) && applicable(t) === true) || allow[0];
1089
+ notes.push(`slide ${n}: the ${family} family takes none of the ${pack.id} titles; drawn under ${treatment}`);
1090
+ } else {
1091
+ treatment = grid.pickTreatment({
1092
+ pack, family, role, override: meta.title, slideNumber: n, used: new Set(), max: Infinity,
1093
+ applicable, onNote: (note) => notes.push(note),
1094
+ });
1095
+ }
1096
+ const candidates = meta.title || titleOnly ? [treatment] : [treatment, ...pack.treatments.filter((t) =>
1097
+ t !== treatment && allow.includes(t) && !["statement", "overlay"].includes(t) && applicable(t) === true)];
1098
+ return {
1099
+ ...base,
1100
+ family,
1101
+ role,
1102
+ treatment,
1103
+ candidates,
1104
+ ctx,
1105
+ statementText,
1106
+ supportText,
1107
+ items,
1108
+ regions: { title: { content: title } },
1109
+ };
1110
+ });
1111
+
1112
+ return { deck: { ...ast.deck }, slides, notes };
1113
+ }
1114
+
730
1115
  // ---------------------------------------------------------------------------
731
1116
  // Exports
732
1117
  // ---------------------------------------------------------------------------
@@ -25,7 +25,8 @@ function parseSimpleYaml(raw) {
25
25
  // Slide-local keys the engine acts on. This set is deliberately closed: a key
26
26
  // the engine ignores has no effect a reader can see, so admitting one buys
27
27
  // nothing and costs the body line it was indistinguishable from.
28
- const SLIDE_KEYS = new Set(["variant"]);
28
+ // `title` names the slide's title treatment when it should differ from the pack's role default.
29
+ const SLIDE_KEYS = new Set(["variant", "title"]);
29
30
 
30
31
  /**
31
32
  * Split the text after a `layout:` line into slide-local keys and body content.
@@ -79,15 +80,16 @@ function load(source) {
79
80
 
80
81
  const fm = parseSimpleYaml(fmRaw);
81
82
 
82
- if (!fm.template) {
83
- throw new Error("Frontmatter must declare a 'template' field");
83
+ if (!fm.template && !fm.tonality) {
84
+ throw new Error("Frontmatter must declare a 'template' or a 'tonality' field");
84
85
  }
85
86
  if (!fm.title) {
86
87
  throw new Error("Frontmatter must declare a 'title' field");
87
88
  }
88
89
 
89
90
  const deck = {
90
- template: fm.template,
91
+ // A tonality-only deck records its pack as the design it was written for.
92
+ template: fm.template || `tonality:${fm.tonality}`,
91
93
  title: fm.title,
92
94
  metadata: {},
93
95
  };
@@ -18,8 +18,8 @@ function bundledFontFiles(family) {
18
18
  const f = (family || "").trim();
19
19
  if (f === "Pretendard") {
20
20
  return [
21
- { file: "pretendard-font/public/static/PretendardGOV-Regular.otf", weight: 400 },
22
- { file: "pretendard-font/public/static/PretendardGOV-Bold.otf", weight: 700 },
21
+ { file: "pretendard-font/public/static/Pretendard-Regular.otf", weight: 400 },
22
+ { file: "pretendard-font/public/static/Pretendard-Bold.otf", weight: 700 },
23
23
  ]
24
24
  .map((e) => ({ ...e, abs: path.join(REPO_ROOT, e.file) }))
25
25
  .filter((e) => fs.existsSync(e.abs));