@litfamily/litopencode 1.0.16 → 1.0.18

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 (119) hide show
  1. package/CHANGELOG.md +13 -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/deliverable-hedge-guard.js +3 -1
  6. package/dist/features.js +2 -2
  7. package/dist/hooks.js +2 -1
  8. package/dist/skills.js +6 -4
  9. package/docs/assets/readme/badge-version.svg +1 -1
  10. package/docs/reference-Ko-KR.md +1 -1
  11. package/package.json +1 -1
  12. package/skills/frontend-ui-ux/data/THIRD-PARTY-NOTICE.txt +1 -3
  13. package/skills/lit-docx/SKILL.md +80 -19
  14. package/skills/lit-docx/references/components.md +253 -0
  15. package/skills/lit-docx/references/direction-step.md +234 -0
  16. package/skills/lit-docx/references/examples/01-report-ko.md +160 -0
  17. package/skills/lit-docx/references/examples/02-brief-ko.md +58 -0
  18. package/skills/lit-docx/references/examples/03-manual-ko.md +107 -0
  19. package/skills/lit-docx/references/examples/04-proposal-ko.md +93 -0
  20. package/skills/lit-docx/references/examples/05-memo-ko.md +42 -0
  21. package/skills/lit-docx/references/examples/06-journal-en.md +84 -0
  22. package/skills/lit-docx/references/examples/07-report-en.md +137 -0
  23. package/skills/lit-docx/references/examples/08-journal-ko.md +93 -0
  24. package/skills/lit-docx/references/examples/09-brief-en.md +64 -0
  25. package/skills/lit-docx/references/page-composition.md +306 -0
  26. package/skills/lit-docx/references/tonalities/brief.md +104 -0
  27. package/skills/lit-docx/references/tonalities/journal.md +101 -0
  28. package/skills/lit-docx/references/tonalities/manual.md +111 -0
  29. package/skills/lit-docx/references/tonalities/memo.md +105 -0
  30. package/skills/lit-docx/references/tonalities/proposal.md +101 -0
  31. package/skills/lit-docx/references/tonalities/report.md +126 -0
  32. package/skills/lit-docx/run.py +2 -1
  33. package/skills/lit-docx/scripts/convert_md_to_docx.py +384 -67
  34. package/skills/lit-docx/scripts/craft_docx.py +204 -1
  35. package/skills/lit-docx/scripts/docx_design.py +2445 -0
  36. package/skills/lit-docx/scripts/docx_layout.py +959 -0
  37. package/skills/lit-docx/scripts/slop_lint.py +23 -6
  38. package/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
  39. package/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
  40. package/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
  41. package/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
  42. package/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
  43. package/skills/lit-docx/templates/tonalities/report.yaml +26 -0
  44. package/skills/lit-pptx/NOTICE.md +1 -1
  45. package/skills/lit-pptx/SKILL.md +108 -19
  46. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
  47. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
  48. package/skills/lit-pptx/references/density-and-fill.md +283 -0
  49. package/skills/lit-pptx/references/direction-step.md +249 -0
  50. package/skills/lit-pptx/references/examples/01-ops-review-ledger-ko.md +173 -0
  51. package/skills/lit-pptx/references/examples/02-launch-pitch-signal-en.md +153 -0
  52. package/skills/lit-pptx/references/examples/03-field-survey-atlas-ko.md +120 -0
  53. package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +132 -0
  54. package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +162 -0
  55. package/skills/lit-pptx/references/examples/06-policy-briefing-gazette-ko.md +176 -0
  56. package/skills/lit-pptx/references/examples/07-annual-story-studio-en.md +131 -0
  57. package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +182 -0
  58. package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +147 -0
  59. package/skills/lit-pptx/references/examples/10-investor-pitch-signal-ko.md +136 -0
  60. package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +177 -0
  61. package/skills/lit-pptx/references/examples/12-exhibition-proposal-studio-ko.md +149 -0
  62. package/skills/lit-pptx/references/layout-families.md +470 -0
  63. package/skills/lit-pptx/references/title-treatments.md +271 -0
  64. package/skills/lit-pptx/references/tonalities/atlas.md +131 -0
  65. package/skills/lit-pptx/references/tonalities/chalk.md +132 -0
  66. package/skills/lit-pptx/references/tonalities/gazette.md +137 -0
  67. package/skills/lit-pptx/references/tonalities/ledger.md +150 -0
  68. package/skills/lit-pptx/references/tonalities/night.md +148 -0
  69. package/skills/lit-pptx/references/tonalities/paper.md +135 -0
  70. package/skills/lit-pptx/references/tonalities/signal.md +147 -0
  71. package/skills/lit-pptx/references/tonalities/studio.md +149 -0
  72. package/skills/lit-pptx/scripts/compile-deck.js +68 -21
  73. package/skills/lit-pptx/scripts/craft-thresholds.json +45 -1
  74. package/skills/lit-pptx/scripts/craft_extras.py +37 -8
  75. package/skills/lit-pptx/scripts/deck_output.py +654 -0
  76. package/skills/lit-pptx/scripts/embed_fonts.py +2 -2
  77. package/skills/lit-pptx/scripts/layout_inventory.py +35 -2
  78. package/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
  79. package/skills/lit-pptx/scripts/lib/font-map.js +4 -4
  80. package/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  81. package/skills/lit-pptx/scripts/lib/layout-resolver.js +403 -21
  82. package/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  83. package/skills/lit-pptx/scripts/lib/render-adapter-html.js +3 -9
  84. package/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -113
  85. package/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
  86. package/skills/lit-pptx/scripts/lib/slide-ast.js +55 -24
  87. package/skills/lit-pptx/scripts/lib/spec-validator.js +2 -2
  88. package/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
  89. package/skills/lit-pptx/scripts/qa_deck.py +48 -13
  90. package/skills/lit-pptx/scripts/validate_pptx.py +25 -7
  91. package/skills/lit-pptx/specs/template-enrollment-contract.md +4 -4
  92. package/skills/lit-pptx/templates/boilerplate_professional.css +13 -13
  93. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  94. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +5 -2
  95. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/capabilities.yaml +20 -20
  96. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -11
  97. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +10 -7
  98. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/capabilities.yaml +17 -17
  99. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/template.yaml +8 -8
  100. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +99 -0
  101. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +104 -0
  102. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +100 -0
  103. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +101 -0
  104. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +100 -0
  105. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +105 -0
  106. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +101 -0
  107. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +99 -0
  108. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
  109. package/skills/lit-typographic-motion/engine/stage/capture.mjs +2 -2
  110. package/skills/lit-typographic-motion/engine/treatment.mjs +1 -1
  111. package/skills/lit-typographic-motion/fonts/pins.json +2 -2
  112. package/skills/lit-typographic-motion/gate.mjs +1 -1
  113. package/skills/lit-typographic-motion/references/stage.md +1 -1
  114. package/skills/lit-typographic-motion/references/style-bibles.md +2 -2
  115. package/skills/lit-typographic-motion/references/treatment.md +1 -1
  116. package/skills/lit-typographic-motion/references/type-craft.md +3 -3
  117. package/skills/managed-skill-manifest.json +288 -44
  118. package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Bold.otf +0 -0
  119. package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Regular.otf +0 -0
@@ -223,7 +223,6 @@ function blockToRegion(blockType, block, layout) {
223
223
  switch (blockType) {
224
224
  case "title": return "title";
225
225
  case "body": return "body";
226
- case "chart": return "chart";
227
226
  case "kpi-table":
228
227
  // content/summary layout defines "table" region; main layout defines "kpi_table" region
229
228
  return (layout === "main") ? "kpi_table" : "table";
@@ -247,21 +246,25 @@ function blockToRegion(blockType, block, layout) {
247
246
  * much vertical room the block needs, so the rule lives here where the geometry
248
247
  * is worked out and the renderer reads it from here.
249
248
  */
250
- function looksLikeKpi(content) {
249
+ function looksLikeKpi(content, o = {}) {
250
+ if (content && content.chart) return false;
251
251
  const headers = (content && content.headers) || [];
252
252
  const rows = (content && content.rows) || [];
253
253
  const cols = headers.length;
254
- if (cols < 2 || cols > 4) return false;
254
+ // Under a tonality a row may hold up to six figures; legacy cards stop at four.
255
+ if (cols < 2 || cols > (o.basis ? 6 : 4)) return false;
255
256
  if (cols === 2) {
256
257
  const pairs = [headers, ...rows];
257
258
  return pairs.length <= 4 && pairs.every((p) => String(p[0]).length <= 12);
258
259
  }
260
+ // Under a tonality a second row may give 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);
259
262
  return rows.length === 1 && rows[0].every((c) => String(c).length <= 12);
260
263
  }
261
264
 
262
- // A KPI badge stacks a small label over a large value, so it stands roughly
263
- // this tall no matter how few rows fed it.
264
- 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");
265
268
 
266
269
  // ---------------------------------------------------------------------------
267
270
  // Capability validation
@@ -274,9 +277,9 @@ const KPI_BLOCK_HEIGHT_IN = 1.75;
274
277
  */
275
278
  const CAPABILITY_RULES = {
276
279
  cover: new Set(["title", "notes"]),
277
- section: new Set(["title", "notes"]),
278
- content: new Set(["title", "key-message", "body", "chart", "image", "notes", "kpi-table", "figure-caption", "table-caption"]),
279
- main: new Set(["title", "key-message", "body", "chart", "main-box", "kpi-table", "image", "notes", "figure-caption", "table-caption"]),
280
+ section: new Set(["title", "notes", "image", "figure-caption"]),
281
+ content: new Set(["title", "key-message", "body", "image", "notes", "kpi-table", "figure-caption", "table-caption"]),
282
+ main: new Set(["title", "key-message", "body", "main-box", "kpi-table", "image", "notes", "figure-caption", "table-caption"]),
280
283
  summary: new Set(["title", "key-message", "summary-group", "image", "kpi-table", "figure-caption", "table-caption"]),
281
284
  closing: new Set(["title", "notes"]),
282
285
  };
@@ -295,7 +298,6 @@ const ALWAYS_ALLOWED = new Set(["notes", ...PLACEMENT_BLOCK_TYPES]);
295
298
  const REGION_TO_BLOCKS = {
296
299
  title: ["title"],
297
300
  body: ["body"],
298
- chart: ["chart"],
299
301
  table: ["kpi-table", "table"],
300
302
  kpi_table: ["kpi-table"],
301
303
  image: ["image"],
@@ -435,6 +437,33 @@ function resolveColumns(slideIndex, layout, regionDefs, block) {
435
437
  * }
436
438
  * @returns {object} — Resolved deck spec with positioned regions
437
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
+
438
467
  function resolve(ast, templateObj) {
439
468
  if (!ast || !ast.slides) {
440
469
  throw new Error("resolve(): invalid AST — expected an object with a slides array");
@@ -442,6 +471,7 @@ function resolve(ast, templateObj) {
442
471
  if (!templateObj) {
443
472
  throw new Error("resolve(): templateObj is required");
444
473
  }
474
+ if (templateObj.pack && !templateObj.pack.legacy) return resolvePack(ast, templateObj);
445
475
 
446
476
  // Parse YAML strings from template object
447
477
  const mapping = typeof templateObj.mapping === "string"
@@ -462,20 +492,34 @@ function resolve(ast, templateObj) {
462
492
  let tableCount = 0;
463
493
 
464
494
  const deckMeta = ast.deck || {};
495
+ const [FIGURE, TABLE] = captionPrefixes(ast);
465
496
 
497
+ const tokens = (templateObj.pack && templateObj.pack.tokens) || {};
498
+ const kpiBlockHeight = (tokens.kpi && tokens.kpi.blockHeight) || 1.75;
466
499
  for (const slide of ast.slides) {
467
- 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);
468
507
  // Auto-number captions across the deck
469
508
  for (const [name, region] of Object.entries(resolved.regions || {})) {
470
509
  if (region.content && region.content.type === "figure-caption") {
471
510
  figureCount++;
472
511
  region.content.number = figureCount;
473
- region.content.prefix = "도";
512
+ region.content.prefix = FIGURE;
474
513
  }
475
- 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") {
476
520
  tableCount++;
477
521
  region.content.number = tableCount;
478
- region.content.prefix = "표";
522
+ region.content.prefix = TABLE;
479
523
  }
480
524
  }
481
525
  resolvedSlides.push(resolved);
@@ -495,10 +539,13 @@ function resolve(ast, templateObj) {
495
539
  * @param {object} [capabilities] — Parsed capabilities.yaml
496
540
  * @returns {object} — Resolved slide spec
497
541
  */
498
- function resolveSlide(slide, mapping, capabilities, deckMeta) {
499
- const { index, layout, blocks } = slide;
542
+ function resolveSlide(slide, mapping, capabilities, deckMeta, kpiBlockHeight) {
543
+ const { index, blocks } = slide;
500
544
 
501
- // 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;
502
549
  const layoutConfig = mapping.layouts[layout] ||
503
550
  (layout === "free" ? FREE_LAYOUT : null);
504
551
  if (!layoutConfig) {
@@ -657,7 +704,7 @@ function resolveSlide(slide, mapping, capabilities, deckMeta) {
657
704
  // lands on top of them.
658
705
  const tableContent = tableRegion.content || {};
659
706
  const needed = looksLikeKpi(tableContent)
660
- ? KPI_BLOCK_HEIGHT_IN
707
+ ? kpiBlockHeight
661
708
  : rowCount * rowH;
662
709
  const tableBottom = tblY + Math.max(needed, tableRegion.h || 0) + 0.3;
663
710
  if ((regions.body.y || 1.044) < tableBottom) {
@@ -701,6 +748,7 @@ function resolveSlide(slide, mapping, capabilities, deckMeta) {
701
748
  return {
702
749
  index,
703
750
  layout,
751
+ family: family.id,
704
752
  layoutSource: layoutConfig.layout_source,
705
753
  decorations,
706
754
  regions,
@@ -721,15 +769,349 @@ function extractContent(block) {
721
769
  if (block.type === "key-message") return block.content || "";
722
770
  if (block.type === "notes") return block.content || "";
723
771
  if (block.type === "body") return { type: "body", items: block.items || [] };
724
- if (block.type === "chart") return { type: "chart", chartType: block.chartType, labels: block.labels, series: block.series };
725
- 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 } : {}) };
726
773
  if (block.type === "image") return { type: "image", src: block.src || "", caption: block.caption || "" };
727
774
  if (block.type === "figure-caption") return { type: "figure-caption", caption: block.caption || "" };
728
- 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 } : {}) };
729
776
  if (block.type === "summary-group") return { type: "summary-group", heading: block.heading, items: block.items || [] };
730
777
  return "";
731
778
  }
732
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
+ * A preview line keeps a bold run-in label bold, followed by a colon ("**결과:** 대출 기간이 …"), unless the
922
+ * label already ends in its own 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
+
733
1115
  // ---------------------------------------------------------------------------
734
1116
  // Exports
735
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
  };
@@ -16,10 +16,10 @@ const A2Z_WEIGHTS = ["Thin", "ExtraLight", "Light", "Regular", "Medium", "SemiBo
16
16
  /** Map a font-family name to bundled OTF file(s): [{path, weight}]. */
17
17
  function bundledFontFiles(family) {
18
18
  const f = (family || "").trim();
19
- if (f === "LitOpenCode Sans") {
19
+ if (f === "Pretendard") {
20
20
  return [
21
- { file: "pretendard-font/public/static/LitOpenCodeSans-Regular.otf", weight: 400 },
22
- { file: "pretendard-font/public/static/LitOpenCodeSans-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));
@@ -148,12 +148,6 @@ function renderContent(content) {
148
148
  return html;
149
149
  }
150
150
 
151
- if (typeof content === "object" && content.type === "chart") {
152
- const header = `<tr><th>Category</th>${content.series.map((s) => `<th>${esc(s.name)}</th>`).join("")}</tr>`;
153
- const rows = content.labels.map((label, i) => `<tr><td>${esc(label)}</td>${content.series.map((s) => `<td style="text-align:right">${esc(s.values[i])}</td>`).join("")}</tr>`).join("");
154
- return `<table aria-label="Chart data" style="width:100%;border-collapse:collapse">${header}${rows}</table>`;
155
- }
156
-
157
151
  // KPI Table content
158
152
  if (typeof content === "object" && content.type === "kpi-table") {
159
153
  let html = '<table style="width:100%;border-collapse:collapse;margin:4px 0;">';