@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
@@ -14,11 +14,12 @@
14
14
  const fs = require("fs");
15
15
  const os = require("os");
16
16
  const path = require("path");
17
- const PptxGenJS = require("pptxgenjs");
18
17
  const { extractContent, looksLikeKpi } = require("./layout-resolver");
18
+ const { LEGACY_TOKENS } = require("./template-registry");
19
19
 
20
- // Narrowest a KPI badge can be and still read as one glance.
21
- const KPI_MIN_CARD_WIDTH_IN = 1.9;
20
+ // Narrowest a KPI badge can be and still read as one glance (template token kpi.minCardWidth).
21
+ let KPI_MIN_CARD_WIDTH_IN = LEGACY_TOKENS.kpi.minCardWidth;
22
+ let KPI_GAP = LEGACY_TOKENS.kpi.gap;
22
23
 
23
24
  const TOOLKIT_ROOT = path.resolve(__dirname, "../..");
24
25
  const ASSETS_AZURE = path.join(TOOLKIT_ROOT, "assets", "azure");
@@ -32,6 +33,10 @@ function decorAssetPath(d) {
32
33
  }
33
34
  const hex = (c) => (c || "#000000").replace("#", "");
34
35
 
36
+ // A template may set its own disclaimer (`text:` on the decoration); the default
37
+ // names no organisation and claims no secrecy.
38
+ const DEFAULT_DISCLAIMER = "※ 이 문서의 사용 범위는 작성자가 지정한 권한을 따릅니다.";
39
+
35
40
  function linkedTableCell(text, options) {
36
41
  const original = cleanMd(text);
37
42
  let display = original;
@@ -55,13 +60,15 @@ function linkedTableCell(text, options) {
55
60
  };
56
61
  }
57
62
 
58
- // ── Text style constants (brand-derived; defaults are the generic Pretendard fallback).
63
+ // ── Text style constants (defaults; every template reassigns them in render()).
59
64
  // These are reassigned from the template at the start of render() so the same
60
65
  // adapter serves any enrolled template (e.g. BOILERPLATE-PRETENDARD).
61
66
  let FONT_BOLD = "Pretendard";
62
67
  let FONT_MEDIUM = "Pretendard";
63
68
  let FONT_LIGHT = "Pretendard";
64
69
  let CHAR_SPACING = -0.7;
70
+ // Chart colours and faces for templates without the rich palette; set per render().
71
+ let PLAIN_CHART_STYLE = { series: ["3C3836", "689D6A", "D79921", "B16286"], ink: "3C3836", ink_muted: "6E6256", line: "CBC2B5", titleFont: "Pretendard", bodyFont: "Pretendard" };
65
72
 
66
73
  function expandHome(p) {
67
74
  if (p === "~") return os.homedir();
@@ -120,43 +127,52 @@ function containBox(x, y, w, h, imageSize) {
120
127
  return { x: x + (w - fittedW) / 2, y, w: fittedW, h };
121
128
  }
122
129
 
123
- // Bullet items
124
- let BULLET_FONT = FONT_BOLD;
125
- const BULLET_SIZE = 16;
126
- let BULLET_COLOR = "00AE41"; // brand bullet color (default accent; reassigned per-template)
127
- let SECTION_ACCENT = "00AE41"; // brand section-card / accent color (default accent)
130
+ // Bullet items. Geometry comes from the template's tokens (bullet.*, secHeader.*, summary.*,
131
+ // toc.*), reassigned in render(); these defaults are the legacy values.
132
+ let BULLET_COLOR = "1D4ED8"; // bullet color (reassigned per template)
133
+ let SECTION_ACCENT = "1D4ED8"; // section-card / accent color (reassigned per template)
128
134
  const BULLET_PREFIX = "\u2022 "; // "• " (bullet + two spaces)
129
- const BULLET_X = 0.59;
130
- const BULLET_W = 8.8;
131
- const BULLET_H = 0.38;
132
- const BULLET_Y_SPACING = 0.42;
133
- const BULLET_LINE_SPACING = 1.25;
135
+ let BULLET_X = LEGACY_TOKENS.bullet.x;
136
+ let BULLET_W = LEGACY_TOKENS.bullet.w;
134
137
 
135
138
  // Section headers
136
139
  let SEC_HEADER_FONT = FONT_BOLD;
137
- const SEC_HEADER_SIZE = 18;
140
+ let SEC_HEADER_SIZE = LEGACY_TOKENS.secHeader.size;
138
141
  const SEC_HEADER_COLOR = "000000";
139
- const SEC_HEADER_X = 0.39;
140
- const SEC_HEADER_W = 9.0;
141
- const SEC_HEADER_H = 0.5;
142
+ let SEC_HEADER_X = LEGACY_TOKENS.secHeader.x;
143
+ let SEC_HEADER_W = LEGACY_TOKENS.secHeader.w;
144
+ let SEC_HEADER_H = LEGACY_TOKENS.secHeader.h;
142
145
 
143
146
  // Summary (narrow width)
144
- const SUMMARY_HEADER_W = 4.3;
145
- const SUMMARY_BULLET_W = 4.8;
146
- const SUMMARY_BULLET_SIZE = 14;
147
- const SUMMARY_BULLET_H = 0.34;
148
- const SUMMARY_BULLET_Y_SPACING = 0.38;
149
- const SUMMARY_BULLET_LINE_SPACING = 1.05;
147
+ let SUMMARY_HEADER_W = LEGACY_TOKENS.summary.headerW;
148
+ let SUMMARY_BULLET_W = LEGACY_TOKENS.summary.bulletW;
149
+ let SUMMARY_BULLET_SIZE = LEGACY_TOKENS.summary.bulletSize;
150
+ let SUMMARY_BULLET_H = LEGACY_TOKENS.summary.bulletH;
151
+ let SUMMARY_BULLET_Y_SPACING = LEGACY_TOKENS.summary.ySpacing;
152
+ let SUMMARY_BULLET_LINE_SPACING = LEGACY_TOKENS.summary.lineSpacing;
150
153
 
151
154
  // TOC items (separate shapes)
152
155
  let TOC_FONT = FONT_BOLD;
153
- const TOC_SIZE = 18;
156
+ let TOC_SIZE = LEGACY_TOKENS.toc.size;
154
157
  const TOC_COLOR = "000000";
155
- const TOC_X = 0.390;
156
- const TOC_W = 7.500;
157
- const TOC_H = 0.550;
158
- const TOC_Y_START = 0.957;
159
- const TOC_Y_SPACING = 0.650;
158
+ let TOC_X = LEGACY_TOKENS.toc.x;
159
+ let TOC_W = LEGACY_TOKENS.toc.w;
160
+ let TOC_H = LEGACY_TOKENS.toc.h;
161
+ let TOC_Y_START = LEGACY_TOKENS.toc.yStart;
162
+ let TOC_Y_SPACING = LEGACY_TOKENS.toc.ySpacing;
163
+
164
+ /** Reassign the token-driven geometry from the template's pack (legacy values when absent). */
165
+ function applyTokens(tokens) {
166
+ const t = tokens || LEGACY_TOKENS;
167
+ ({ x: BULLET_X, w: BULLET_W } = t.bullet);
168
+ ({ size: SEC_HEADER_SIZE, x: SEC_HEADER_X, w: SEC_HEADER_W, h: SEC_HEADER_H } = t.secHeader);
169
+ ({ headerW: SUMMARY_HEADER_W, bulletW: SUMMARY_BULLET_W, bulletSize: SUMMARY_BULLET_SIZE, bulletH: SUMMARY_BULLET_H,
170
+ ySpacing: SUMMARY_BULLET_Y_SPACING, lineSpacing: SUMMARY_BULLET_LINE_SPACING } = t.summary);
171
+ ({ size: TOC_SIZE, x: TOC_X, w: TOC_W, h: TOC_H, yStart: TOC_Y_START, ySpacing: TOC_Y_SPACING } = t.toc);
172
+ ({ pad: CARD_PAD, chip: CARD_CHIP, minH: CARD_MIN_H, gap: CARD_GAP, radius: CARD_RADIUS } = t.card);
173
+ ({ minCardWidth: KPI_MIN_CARD_WIDTH_IN, gap: KPI_GAP } = t.kpi);
174
+ NOTICE = { ...t.notice };
175
+ }
160
176
 
161
177
  function weightedTextLength(text) {
162
178
  let total = 0;
@@ -395,7 +411,7 @@ function estimateDiagnosticCardHeight(card, w) {
395
411
  return Math.max(0.56, Math.min(2.35, padY * 2 + titleH + bodyH));
396
412
  }
397
413
 
398
- function renderCramStressBody(slide, items, pageW, pageH) {
414
+ function renderCramStressBody(slide, items, pageH) {
399
415
  const cards = bodyItemsToDiagnosticCards(items);
400
416
  const columns = 3;
401
417
  const groups = cards.length ? splitCardsByWeight(cards, columns) : splitLinesByWeight(bodyItemsToDiagnosticLines(items), columns).map((group) => [{ title: "", body: group }]);
@@ -541,7 +557,7 @@ function renderCramStressSlide(slide, slideSpec, pageW, pageH, sourceDir) {
541
557
  });
542
558
  }
543
559
  if (regions.body && regions.body.content && regions.body.content.type === "body") {
544
- renderCramStressBody(slide, regions.body.content.items || [], pageW, pageH);
560
+ renderCramStressBody(slide, regions.body.content.items || [], pageH);
545
561
  }
546
562
  if (regions.image && regions.image.content) {
547
563
  renderCramStressImageSheet(slide, regions.image.content, sourceDir, pageW, pageH);
@@ -605,7 +621,7 @@ function buildFontRoles(templateObj) {
605
621
  * Add decoration elements to a pptxgenjs slide.
606
622
  * Note: slide_number is intentionally omitted — the reference template has none.
607
623
  */
608
- function addDecorations(slide, decorations, slideIdx, fontRoles) {
624
+ function addDecorations(slide, decorations, fontRoles) {
609
625
  if (!decorations || !Array.isArray(decorations)) return;
610
626
 
611
627
  for (const d of decorations) {
@@ -681,9 +697,9 @@ function addDecorations(slide, decorations, slideIdx, fontRoles) {
681
697
  x: mX, y: mY, w: mW, h: mH,
682
698
  line: { color: bColor, width: bPt },
683
699
  });
684
- slide.addText("내부 검토", {
700
+ slide.addText(d.text || "내부 검토", {
685
701
  x: mX, y: mY, w: mW, h: mH,
686
- fontFace: role.font || "Pretendard",
702
+ fontFace: role.font || FONT_MEDIUM,
687
703
  fontSize: role.size || 14,
688
704
  color: role.color || "C00000",
689
705
  align: role.align || "center",
@@ -693,7 +709,7 @@ function addDecorations(slide, decorations, slideIdx, fontRoles) {
693
709
  } else if (d.type === "disclaimer") {
694
710
  const role = fontRoles.disclaimer || {};
695
711
  slide.addText(
696
- "※ 이 문서의 사용 범위는 작성자가 지정한 권한을 따릅니다.",
712
+ d.text || DEFAULT_DISCLAIMER,
697
713
  {
698
714
  x: d.x || 0,
699
715
  y: d.y || 0,
@@ -983,7 +999,7 @@ function addSummaryShapes(slide, content, region) {
983
999
 
984
1000
  /**
985
1001
  * Add TOC items as separate text shapes, matching TEMPLATE-PPTX.pptx exactly.
986
- * Each item is: "N. text" in Pretendard 18pt, w=7.5, h=0.55, spaced 0.65" apart.
1002
+ * Each item is: "N. text" in the bold face at 18pt, w=7.5, h=0.55, spaced 0.65" apart.
987
1003
  */
988
1004
  function addTocShapes(slide, items) {
989
1005
  let y = TOC_Y_START;
@@ -1104,7 +1120,7 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1104
1120
  fontSize: opts.fontSize,
1105
1121
  color: opts.color,
1106
1122
  ...(opts.charSpacing != null ? { charSpacing: opts.charSpacing } : {}),
1107
- }), opts);
1123
+ }), name === "title" ? { ...opts, objectName: "title@legacy" } : opts);
1108
1124
  return;
1109
1125
  }
1110
1126
 
@@ -1120,7 +1136,14 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1120
1136
  }
1121
1137
 
1122
1138
  // ── KPI Table ──────────────────────────────────────────────────────
1139
+ if (content.type === "kpi-table" && content.chart) {
1140
+ renderChart(slide, content, region, PLAIN_CHART_STYLE, regionBottom);
1141
+ return;
1142
+ }
1123
1143
  if (content.type === "kpi-table") {
1144
+ const numeric = numericColumns(content.headers || [], content.rows || []);
1145
+ const metrics = tableMetrics((content.rows || []).length + 1, region.y || 0, regionBottom,
1146
+ { rowH: 0.35, maxRowH: 0.55, headerSize: 14, dataSize: 14 });
1124
1147
  const headerRole = fontRoles[region.font_role_header] || fontRoles.table_header;
1125
1148
  const dataRole = fontRoles[region.font_role_data] || fontRoles.table_data;
1126
1149
 
@@ -1135,7 +1158,7 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1135
1158
  fontSize: headerRole ? headerRole.size : 14,
1136
1159
  fontFace: headerRole ? headerRole.font : FONT_BOLD,
1137
1160
  color: headerRole ? headerRole.color : "000000",
1138
- align: headerRole ? headerRole.align : "left",
1161
+ align: numeric[content.headers.indexOf(h)] ? "right" : (headerRole ? headerRole.align : "left"),
1139
1162
  valign: "middle",
1140
1163
  ...(headerRole && headerRole.charSpacing != null
1141
1164
  ? { charSpacing: headerRole.charSpacing }
@@ -1151,11 +1174,11 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1151
1174
  if (content.rows) {
1152
1175
  for (const row of content.rows) {
1153
1176
  tableData.push(
1154
- row.map((cell) => linkedTableCell(cell, {
1177
+ row.map((cell, ci) => linkedTableCell(cell, {
1155
1178
  fontSize: dataRole ? dataRole.size : 14,
1156
1179
  fontFace: dataRole ? dataRole.font : FONT_MEDIUM,
1157
1180
  color: dataRole ? dataRole.color : "000000",
1158
- align: dataRole ? dataRole.align : "left",
1181
+ align: numeric[ci] ? "right" : (dataRole ? dataRole.align : "left"),
1159
1182
  valign: "middle",
1160
1183
  ...(dataRole && dataRole.charSpacing != null
1161
1184
  ? { charSpacing: dataRole.charSpacing }
@@ -1174,9 +1197,9 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1174
1197
  y: tblY,
1175
1198
  w: tblW,
1176
1199
  border: { pt: 1.5, color: "000000" },
1177
- colW: Array(numCols).fill(tblW / numCols),
1200
+ colW: numCols > 0 && content.headers ? proportionalWidths(content.headers, content.rows || [], tblW) : Array(numCols).fill(tblW / numCols),
1178
1201
  margin: [3.6, 7.2, 3.6, 7.2],
1179
- rowH: 0.35,
1202
+ rowH: metrics.rowH,
1180
1203
  });
1181
1204
  return;
1182
1205
  }
@@ -1297,7 +1320,7 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1297
1320
  }
1298
1321
  }
1299
1322
 
1300
- // ── AZURE rich rendering (isolated; only used when template.render_style==="azure") ──
1323
+ // ── Rich card, KPI and table rendering (templates with the rich-blocks capability) ──
1301
1324
 
1302
1325
  /** Split a title string into runs, emphasizing **word** in the primary color. */
1303
1326
  function azureTitleRuns(text, P) {
@@ -1315,6 +1338,7 @@ function cleanMd(s) {
1315
1338
  .replace(/^#{1,6}\s+/, "")
1316
1339
  .replace(/^\s*[*-]\s+/, "")
1317
1340
  .replace(/\*\*(.+?)\*\*/g, "$1")
1341
+ .replace(/(?<![\w*])\*([^*\s](?:[^*]*[^*\s])?)\*(?![\w*])/gu, "$1")
1318
1342
  .replace(/`/g, "")
1319
1343
  .trim();
1320
1344
  }
@@ -1328,12 +1352,15 @@ function mdRuns(text, runOpts = {}) {
1328
1352
  .replace(/^#{1,6}\s+/, "")
1329
1353
  .replace(/^\s*[*-]\s+/, "")
1330
1354
  .replace(/`/g, "");
1331
- const parts = clean.split(/(\*\*[^*]+\*\*)/g).filter((p) => p !== "");
1355
+ // **x** is bold; *x* (a journal or book name) is italic.
1356
+ const parts = clean.split(/(\*\*[^*]+\*\*|(?<![\w*])\*[^*\s](?:[^*]*[^*\s])?\*(?![\w*]))/gu).filter((p) => p !== "");
1332
1357
  if (parts.length === 0) return [{ text: "", options: { ...runOpts } }];
1333
1358
  return parts.map((p) =>
1334
1359
  p.startsWith("**") && p.endsWith("**")
1335
1360
  ? { text: p.slice(2, -2), options: { ...runOpts, bold: true } }
1336
- : { text: p, options: { ...runOpts } }
1361
+ : p.length > 2 && p.startsWith("*") && p.endsWith("*")
1362
+ ? { text: p.slice(1, -1), options: { ...runOpts, italic: true } }
1363
+ : { text: p, options: { ...runOpts } }
1337
1364
  );
1338
1365
  }
1339
1366
 
@@ -1368,15 +1395,63 @@ function bodyToCards(items) {
1368
1395
  return cards.slice(0, 4);
1369
1396
  }
1370
1397
 
1398
+ // Card geometry shared by drawing and sizing, so a card is exactly as tall as its text
1399
+ // (template tokens card.*; reassigned in render()).
1400
+ let CARD_PAD = LEGACY_TOKENS.card.pad;
1401
+ let CARD_CHIP = LEGACY_TOKENS.card.chip;
1402
+ let CARD_MIN_H = LEGACY_TOKENS.card.minH;
1403
+ let CARD_GAP = LEGACY_TOKENS.card.gap;
1404
+ let CARD_RADIUS = LEGACY_TOKENS.card.radius;
1405
+
1406
+ // Primary reading text stays at or above the 12 pt floor of the design laws.
1407
+ function cardBodySize(card) {
1408
+ return card.lines && card.lines.length > 4 ? 12 : 13;
1409
+ }
1410
+
1411
+ // Card text height: the conservative wrap count, but line boxes at their real
1412
+ // leading (estimateTextHeight pads every paragraph, which left short cards hollow).
1413
+ function cardTextHeight(text, innerW, fontSize, lineSpacing) {
1414
+ return (estimateWrappedLines(text, innerW, fontSize) * fontSize * lineSpacing * 1.2) / 72;
1415
+ }
1416
+
1417
+ function cardHeadingHeight(card, innerW) {
1418
+ return card.heading ? cardTextHeight(card.heading, innerW, 15, 1.05) + 0.06 : 0;
1419
+ }
1420
+
1421
+ function cardBodyHeight(card, innerW) {
1422
+ if (card.bodyRuns && card.bodyRuns.length) return card.bodyHeight || 0;
1423
+ const fs = cardBodySize(card);
1424
+ return (card.lines || []).reduce((sum, line) => sum + cardTextHeight(line, innerW, fs, 1.3), 0.1);
1425
+ }
1426
+
1427
+ /** Height a card needs for its chip, heading and body at the width it is drawn. */
1428
+ function azureCardHeight(card, w) {
1429
+ const innerW = w - 2 * CARD_PAD;
1430
+ const head = card.heading ? 0.18 + Math.max(0.4, cardHeadingHeight(card, innerW)) + 0.04 : 0.18;
1431
+ return CARD_PAD + CARD_CHIP + head + cardBodyHeight(card, innerW) + CARD_PAD;
1432
+ }
1433
+
1434
+ /**
1435
+ * The height a row of cards is drawn at: the tallest card's content, never taller
1436
+ * than the region the template reserves and never shorter than CARD_MIN_H. A fixed
1437
+ * region height left short cards mostly empty; the space below a short row is free
1438
+ * for the takeaway, and the craft gate reports a slide that stays thin.
1439
+ */
1440
+ function fittedCardHeight(cards, w, regionH) {
1441
+ const need = Math.max(...cards.map((c) => azureCardHeight(c, w)));
1442
+ return Math.min(regionH, Math.max(CARD_MIN_H, need));
1443
+ }
1444
+
1371
1445
  /** Draw one rounded card with a numbered chip, heading, and body lines. */
1372
1446
  function drawAzureCard(slide, x, y, w, h, card, idx, P, emphasized) {
1373
1447
  const bg = emphasized ? hex(P.primary_deep) : hex(P.tint);
1374
1448
  const headColor = emphasized ? "FFFFFF" : hex(P.ink);
1375
1449
  const bodyColor = emphasized ? hex(P.emphasisTextColor) : hex(P.ink_muted);
1376
- slide.addShape("roundRect", { x, y, w, h, rectRadius: 0.16, fill: { color: bg }, line: { type: "none" } });
1377
- const pad = 0.30;
1450
+ slide.addShape("roundRect", { x, y, w, h, rectRadius: CARD_RADIUS, fill: { color: bg }, line: { type: "none" } });
1451
+ const pad = CARD_PAD;
1452
+ const innerW = w - 2 * pad;
1378
1453
  // numbered chip
1379
- const chip = 0.52;
1454
+ const chip = CARD_CHIP;
1380
1455
  slide.addShape("roundRect", {
1381
1456
  x: x + pad, y: y + pad, w: chip, h: chip, rectRadius: 0.12,
1382
1457
  fill: { color: emphasized ? "FFFFFF" : hex(P.primary) }, line: { type: "none" },
@@ -1389,43 +1464,45 @@ function drawAzureCard(slide, x, y, w, h, card, idx, P, emphasized) {
1389
1464
  color: emphasized ? hex(P.primary_deep) : "FFFFFF",
1390
1465
  margin: 0, lineSpacingMultiple: 1,
1391
1466
  });
1467
+ const headH = cardHeadingHeight(card, innerW);
1392
1468
  if (card.heading) {
1393
1469
  slide.addText(card.heading, {
1394
- x: x + pad, y: y + pad + chip + 0.18, w: w - 2 * pad, h: 0.4,
1470
+ x: x + pad, y: y + pad + chip + 0.18, w: innerW, h: Math.max(0.4, headH),
1395
1471
  fontFace: P.titleFont, fontSize: 15, bold: true, color: headColor, align: "left", valign: "top",
1396
1472
  charSpacing: -0.3,
1397
1473
  });
1398
1474
  }
1399
- const bodyY = y + pad + chip + (card.heading ? 0.62 : 0.18);
1400
- const bodyH = h - (bodyY - y) - 0.18;
1475
+ const bodyY = y + pad + chip + 0.18 + (card.heading ? Math.max(0.4, headH) + 0.04 : 0);
1476
+ const bodyH = Math.max(0.3, h - (bodyY - y) - 0.18);
1401
1477
  if (card.bodyRuns && card.bodyRuns.length) {
1402
1478
  slide.addText(card.bodyRuns, {
1403
- x: x + pad, y: bodyY, w: w - 2 * pad, h: bodyH,
1479
+ x: x + pad, y: bodyY, w: innerW, h: bodyH,
1404
1480
  color: bodyColor, align: "left", valign: "top", lineSpacingMultiple: card.lineSpacing || 1.28, charSpacing: -0.2,
1405
1481
  });
1406
1482
  } else if (card.lines && card.lines.length) {
1407
1483
  // Shrink body font when there are many lines so it never exceeds the frame.
1408
- const fs = card.lines.length > 4 ? 10.5 : 11.5;
1484
+ const fs = cardBodySize(card);
1409
1485
  slide.addText(card.lines.join("\n"), {
1410
- x: x + pad, y: bodyY, w: w - 2 * pad, h: bodyH,
1486
+ x: x + pad, y: bodyY, w: innerW, h: bodyH,
1411
1487
  fontFace: P.bodyFont, fontSize: fs, color: bodyColor, align: "left", valign: "top",
1412
1488
  lineSpacingMultiple: 1.3, charSpacing: -0.2,
1413
1489
  });
1414
1490
  }
1415
1491
  }
1416
1492
 
1417
- /** Render content/main body as a row of cards. */
1493
+ /** Render content/main body as a row of cards, as tall as the longest card needs. */
1418
1494
  function renderAzureCards(slide, items, region, P) {
1419
1495
  const cards = bodyToCards(items);
1420
1496
  if (!cards.length) return;
1421
1497
  const n = cards.length;
1422
1498
  const x0 = region.x || 0.6, y0 = region.y || 1.6, w = region.w || 12.1;
1423
- const h = region.h || 3.0;
1424
- const gap = 0.28;
1499
+ const gap = CARD_GAP;
1425
1500
  const cardW = (w - (n - 1) * gap) / n;
1501
+ const h = fittedCardHeight(cards, cardW, region.h || 3.0);
1426
1502
  cards.forEach((c, i) => {
1427
1503
  drawAzureCard(slide, x0 + i * (cardW + gap), y0, cardW, h, c, i, P, n >= 3 && i === n - 1);
1428
1504
  });
1505
+ return y0 + h;
1429
1506
  }
1430
1507
 
1431
1508
  /** Flatten body/summary items (sections + children + bullets) into styled runs.
@@ -1435,12 +1512,12 @@ function itemsToRuns(items, P, scale = 1) {
1435
1512
  const runs = [];
1436
1513
  for (const it of items || []) {
1437
1514
  if (it.type === "section") {
1438
- runs.push({ text: cleanMd(it.heading || ""), options: { bold: true, fontFace: P.titleFont, fontSize: s(12.5), color: hex(P.ink), breakLine: true } });
1515
+ runs.push({ text: cleanMd(it.heading || ""), options: { bold: true, fontFace: P.titleFont, fontSize: s(14), color: hex(P.ink), breakLine: true } });
1439
1516
  for (const c of it.children || []) {
1440
- runs.push({ text: " " + cleanMd(c.text || ""), options: { fontFace: P.bodyFont, fontSize: s(11), color: hex(P.ink_muted), breakLine: true } });
1517
+ runs.push({ text: " " + cleanMd(c.text || ""), options: { fontFace: P.bodyFont, fontSize: s(12.5), color: hex(P.ink_muted), breakLine: true } });
1441
1518
  }
1442
1519
  } else if (it.text) {
1443
- runs.push({ text: (it.type === "numbered" ? " " : "• ") + cleanMd(it.text), options: { fontFace: P.bodyFont, fontSize: s(11.5), color: hex(P.ink_muted), breakLine: true } });
1520
+ runs.push({ text: (it.type === "numbered" ? " " : "• ") + cleanMd(it.text), options: { fontFace: P.bodyFont, fontSize: s(13), color: hex(P.ink_muted), breakLine: true } });
1444
1521
  }
1445
1522
  }
1446
1523
  return runs;
@@ -1452,76 +1529,229 @@ function estimateGroupHeight(items, innerW, scale, lineSpacing) {
1452
1529
  let hsum = 0;
1453
1530
  for (const it of items || []) {
1454
1531
  if (it.type === "section") {
1455
- hsum += estimateTextHeight(it.heading || "", innerW, 12.5 * scale, lineSpacing);
1456
- for (const c of it.children || []) hsum += estimateTextHeight(" " + (c.text || ""), innerW - 0.1, 11 * scale, lineSpacing);
1532
+ hsum += estimateTextHeight(it.heading || "", innerW, 14 * scale, lineSpacing);
1533
+ for (const c of it.children || []) hsum += estimateTextHeight(" " + (c.text || ""), innerW - 0.1, 12.5 * scale, lineSpacing);
1534
+ } else if (it.text) {
1535
+ hsum += estimateTextHeight(it.text, innerW, 13 * scale, lineSpacing);
1536
+ }
1537
+ }
1538
+ return hsum;
1539
+ }
1540
+
1541
+ /** The height a group's runs take at their real leading, for sizing (not fitting) the card. */
1542
+ function groupTextHeight(items, innerW, scale, lineSpacing) {
1543
+ let hsum = 0.1;
1544
+ for (const it of items || []) {
1545
+ if (it.type === "section") {
1546
+ hsum += cardTextHeight(it.heading || "", innerW, 14 * scale, lineSpacing);
1547
+ for (const c of it.children || []) hsum += cardTextHeight(" " + (c.text || ""), innerW - 0.1, 12.5 * scale, lineSpacing);
1457
1548
  } else if (it.text) {
1458
- hsum += estimateTextHeight(it.text, innerW, 11.5 * scale, lineSpacing);
1549
+ hsum += cardTextHeight("• " + it.text, innerW, 13 * scale, lineSpacing);
1459
1550
  }
1460
1551
  }
1461
1552
  return hsum;
1462
1553
  }
1463
1554
 
1464
- /** Render a single summary-group as one card (heading + items, iterative shrink-to-fit). */
1465
- function renderAzureGroupCard(slide, content, region, P, idx) {
1466
- const w = region.w || 5.6, h = region.h || 2.4;
1467
- const innerW = w - 0.60;
1468
- const innerH = h - 0.30 - 0.52 - (content.heading ? 0.62 : 0.18) - 0.18;
1555
+ /** A summary group as a card: the largest font scale (1 down to 0.55) whose text fits
1556
+ * `maxH`, and the height that text needs at that scale. */
1557
+ function groupCard(content, w, maxH, P) {
1558
+ const innerW = w - 2 * CARD_PAD;
1559
+ const heading = cleanMd(content.heading || "");
1560
+ const headH = heading ? 0.18 + Math.max(0.4, cardHeadingHeight({ heading }, innerW)) + 0.04 : 0.18;
1561
+ const fixed = CARD_PAD + CARD_CHIP + headH + 0.18;
1469
1562
  let scale = 0.55, ls = 1.12;
1470
1563
  for (const s of [1, 0.92, 0.85, 0.78, 0.72, 0.66, 0.6, 0.55]) {
1471
- if (estimateGroupHeight(content.items, innerW, s, s < 1 ? 1.12 : 1.28) <= innerH) { scale = s; ls = s < 1 ? 1.12 : 1.28; break; }
1564
+ if (estimateGroupHeight(content.items, innerW, s, s < 1 ? 1.12 : 1.28) <= maxH - fixed) { scale = s; ls = s < 1 ? 1.12 : 1.28; break; }
1472
1565
  }
1473
- const card = {
1474
- heading: cleanMd(content.heading || ""),
1475
- bodyRuns: itemsToRuns(content.items, P, scale),
1476
- lineSpacing: ls,
1566
+ const bodyHeight = groupTextHeight(content.items, innerW, scale, ls);
1567
+ return {
1568
+ card: { heading, bodyRuns: itemsToRuns(content.items, P, scale), lineSpacing: ls, bodyHeight },
1569
+ need: Math.min(maxH, fixed + bodyHeight + CARD_PAD - 0.18),
1477
1570
  };
1571
+ }
1572
+
1573
+ /** Render a single summary-group as one card, at `opts.height` (shared by the
1574
+ * groups of one slide) or at the height its own text needs. */
1575
+ function renderAzureGroupCard(slide, content, region, P, idx, height) {
1576
+ const w = region.w || 5.6, maxH = region.h || 2.4;
1577
+ const { card, need } = groupCard(content, w, maxH, P);
1578
+ const h = Math.min(maxH, Math.max(CARD_MIN_H, height || need));
1478
1579
  drawAzureCard(slide, region.x || 0.6, region.y || 1.6, w, h, card, idx, P, false);
1479
1580
  }
1480
1581
 
1481
1582
  /** Render a general data table (palette-styled): primary header, zebra tint rows. */
1482
- function renderAzureTable(slide, content, region, P) {
1583
+ // ── Tables and charts that fill their region ────────────────────────────────
1584
+
1585
+ // A cell reads as a number when, after units and signs, only digits remain.
1586
+ const NUMERIC_CELL = /^(?:[▲▼△▽]\s*)?[+\-−±]?\s*[\d.,]+\s*(?:%p?|배|x|pt|bp|[가-힣]{1,3}|[A-Za-z]{1,3})?$/u;
1587
+ const isNumericCell = (text) => NUMERIC_CELL.test(cleanMd(String(text)).trim());
1588
+
1589
+ /** Columns whose data cells are all numbers are right-aligned; a dash for a missing value is blank. */
1590
+ function numericColumns(headers, rows) {
1591
+ return headers.map((_, ci) => {
1592
+ const cells = rows.map((r) => r[ci]).filter((c) => c != null && !/^[\s—–\-]*$/u.test(cleanMd(String(c))));
1593
+ return cells.length > 0 && cells.every(isNumericCell);
1594
+ });
1595
+ }
1596
+
1597
+ /** Column widths proportional to the longest cell, Hangul counted wider, with a floor. */
1598
+ function proportionalWidths(headers, rows, totalW) {
1599
+ const len = (t) => [...cleanMd(String(t || ""))].reduce((n, ch) => n + (/[\u3131-\uD7A3]/u.test(ch) ? 1.7 : 1), 0);
1600
+ const want = headers.map((h, ci) => Math.max(len(h), ...rows.map((r) => len(r[ci])), 4));
1601
+ const floor = Math.min(0.9, totalW / headers.length);
1602
+ const sum = want.reduce((a, b) => a + b, 0);
1603
+ let widths = want.map((w) => Math.max(floor, (w / sum) * totalW));
1604
+ const scale = totalW / widths.reduce((a, b) => a + b, 0);
1605
+ widths = widths.map((w) => w * scale);
1606
+ return widths;
1607
+ }
1608
+
1609
+ /**
1610
+ * Row height and font size for a table that should use the room it has: rows
1611
+ * grow toward `bottom` (up to a comfortable maximum) instead of leaving the lower
1612
+ * half of the slide empty, and a roomy table gets a larger font.
1613
+ */
1614
+ function tableMetrics(rowCount, y, bottom, base) {
1615
+ const room = Math.max(0, (bottom || y + rowCount * base.rowH) - y);
1616
+ const rowH = Math.min(base.maxRowH, Math.max(base.rowH, (room * 0.92) / rowCount));
1617
+ const scale = rowH >= 0.55 ? 1.2 : rowH >= 0.47 ? 1.1 : 1;
1618
+ return { rowH, headerSize: +(base.headerSize * scale).toFixed(1), dataSize: +(base.dataSize * scale).toFixed(1) };
1619
+ }
1620
+
1621
+ function renderAzureTable(slide, content, region, P, bottom) {
1483
1622
  const headers = content.headers || [];
1484
1623
  const rows = content.rows || [];
1485
1624
  if (!headers.length) return;
1486
1625
  const x = region.x || 0.6, y = region.y || 1.6, w = region.w || 6.0;
1487
- // A categorical numeric series is more legible as an editable chart. Keep
1488
- // mixed data, short lists, and lookup tables as tables.
1489
- const number = (value) => {
1490
- const clean = cleanMd(value).replace(/[,\s%₩$]/g, "");
1491
- return /^-?\d+(?:\.\d+)?$/.test(clean) ? Number(clean) : null;
1492
- };
1493
- if (rows.length >= 3 && headers.length >= 2 &&
1494
- rows.every((row) => row.length === headers.length && row[0] &&
1495
- row.slice(1).every((value) => number(value) !== null))) {
1496
- slide.addChart("bar", headers.slice(1).map((name, index) => ({
1497
- name: cleanMd(name),
1498
- labels: rows.map((row) => cleanMd(row[0])),
1499
- values: rows.map((row) => number(row[index + 1])),
1500
- })), {
1501
- x, y, w, h: Math.max(region.h || 0, 3.7),
1502
- catAxisLabelFontFace: P.bodyFont, catAxisLabelFontSize: 11,
1503
- valAxisLabelFontFace: P.bodyFont, valAxisLabelFontSize: 11,
1504
- showLegend: headers.length > 2, showTitle: false,
1505
- showValue: false, showBorder: false,
1506
- showCatName: false, chartColors: [hex(P.primary), hex(P.primary_deep), hex(P.ink_muted)],
1507
- showMarker: false, showLine: false,
1508
- });
1509
- return;
1510
- }
1626
+ const numeric = numericColumns(headers, rows);
1627
+ const m = tableMetrics(rows.length + 1, y, bottom, { rowH: 0.4, maxRowH: 0.72, headerSize: 12, dataSize: 11.5 });
1511
1628
  const tableRows = [];
1512
- tableRows.push(headers.map((h) => ({
1629
+ tableRows.push(headers.map((h, ci) => ({
1513
1630
  text: cleanMd(h),
1514
- options: { bold: true, color: "FFFFFF", fill: { color: hex(P.primary) }, fontFace: P.titleFont, fontSize: 12, align: "left", valign: "middle" },
1631
+ options: { bold: true, color: "FFFFFF", fill: { color: hex(P.primary) }, fontFace: P.titleFont, fontSize: m.headerSize, align: numeric[ci] ? "right" : "left", valign: "middle" },
1515
1632
  })));
1516
1633
  rows.forEach((r, ri) => {
1517
- tableRows.push(r.map((c) => linkedTableCell(c, {
1518
- color: hex(P.ink), fill: { color: ri % 2 ? "FFFFFF" : hex(P.tint) }, fontFace: P.bodyFont, fontSize: 11.5,
1519
- align: number(c) === null ? "left" : "right", valign: "middle",
1634
+ tableRows.push(r.map((c, ci) => linkedTableCell(c, {
1635
+ color: hex(P.ink), fill: { color: ri % 2 ? "FFFFFF" : hex(P.tint) }, fontFace: P.bodyFont, fontSize: m.dataSize,
1636
+ align: numeric[ci] ? "right" : "left", valign: "middle", bold: ci === 0 && !numeric[0],
1520
1637
  })));
1521
1638
  });
1522
1639
  slide.addTable(tableRows, {
1523
- x, y, w, colW: Array(headers.length).fill(w / headers.length),
1524
- border: { type: "solid", color: hex(P.line), pt: 0.5 }, rowH: 0.4, margin: 0.06, autoPage: false,
1640
+ x, y, w, colW: proportionalWidths(headers, rows, w),
1641
+ border: { type: "solid", color: hex(P.line), pt: 0.5 }, rowH: m.rowH, margin: [0.04, 0.12, 0.04, 0.12], autoPage: false,
1642
+ });
1643
+ }
1644
+
1645
+ /** Parse "1,234", "▲12.5%", "−3" into a number; anything else is null. */
1646
+ function chartNumber(text) {
1647
+ const t = cleanMd(String(text || "")).trim().replace(/[,\s]/g, "").replace(/^[▲△+]/u, "").replace(/^[▼▽−]/u, "-");
1648
+ const m = t.match(/^-?\d+(?:\.\d+)?/);
1649
+ return m ? parseFloat(m[0]) : null;
1650
+ }
1651
+
1652
+ const CHART_TYPE = { bar: "bar", column: "bar", stacked: "bar", line: "line", area: "area", pie: "pie", doughnut: "doughnut" };
1653
+
1654
+ /**
1655
+ * A native PowerPoint chart (editable in PowerPoint: right-click > Edit Data)
1656
+ * from a `::: chart` block. It takes the whole region down to `bottom`.
1657
+ */
1658
+ function renderChart(slide, content, region, style, bottom) {
1659
+ const headers = content.headers || [];
1660
+ const rows = content.rows || [];
1661
+ const spec = content.chart || {};
1662
+ const kind = CHART_TYPE[spec.type] || "bar";
1663
+ const labels = rows.map((r) => cleanMd(String(r[0] || "")));
1664
+ const series = headers.slice(1).map((name, si) => ({
1665
+ name: cleanMd(String(name)),
1666
+ labels,
1667
+ // A dash or empty cell stays null and is written as a blank point; the line breaks instead of plunging to zero.
1668
+ values: rows.map((r) => chartNumber(r[si + 1])),
1669
+ }));
1670
+ // Data labels keep the decimals the table writes: beside a table that says 18.0 the chart says 18.0 too.
1671
+ const decimals = Math.min(3, Math.max(0, ...rows.flatMap((r) => r.slice(1).map((v) => ((/\.(\d+)/u.exec(cleanMd(String(v == null ? "" : v)).replace(/,/gu, "")) || [, ""])[1]).length))));
1672
+ const x = region.x || 0.6, y = region.y || 1.6, w = region.w || 8;
1673
+ const h = Math.max(1.6, ((bottom || y + (region.h || 3.2)) - y));
1674
+ const colors = style.series.map((c) => hex(c));
1675
+ const round = kind === "pie" || kind === "doughnut";
1676
+ const data = round ? series.slice(0, 1) : series;
1677
+ // Crowding, estimated in points: a category label wider than its slot is slanted; side-by-side
1678
+ // value labels wider than their bars collide, so the values are read off a shown axis instead.
1679
+ const ems = (t) => [...String(t)].reduce((a, ch) => a + (/[\u3131-\uD7A3]/u.test(ch) ? 0.94 : /\s/u.test(ch) ? 0.28 : 0.56), 0);
1680
+ const labelPt = style.labelSize || 11;
1681
+ const slot = (w * 72 * 0.85) / Math.max(1, labels.length);
1682
+ // A renderer adds space between Hangul and digits ("25 년 2Q"), so a label needs a quarter of its slot spare.
1683
+ // Horizontal bars run their categories down the side, where LibreOffice drops slanted labels.
1684
+ const slanted = !round && kind !== "line" && spec.type !== "bar" && Math.max(0, ...labels.map(ems)) * labelPt > slot * 0.75;
1685
+ const clustered = kind === "bar" && spec.type !== "stacked" && data.length > 1;
1686
+ const valueW = Math.max(0, ...data.flatMap((d) => d.values.map((v) => ems(Number(v).toLocaleString("en-US"))))) * labelPt;
1687
+ // Lines of two or more series share the plot's rows, so a label over each point covers its neighbours.
1688
+ const crowded = !round && ((clustered && valueW > (slot / 1.6 / data.length) * 1.1) || (kind === "line" && data.length > 1));
1689
+ const values = data.flatMap((d) => d.values).filter((v) => v !== null);
1690
+ // Each row of a multi-series horizontal bar chart holds several thin bars; thinner than a label line, their value labels touch.
1691
+ const horizontal = !round && spec.type === "bar";
1692
+ const dataPt = style.labelSize || (round ? 12 : 10);
1693
+ const barPt = (h * 72 - 54) / Math.max(1, labels.length) / (data.length + 0.6);
1694
+ const thin = horizontal && data.length > 1 && spec.labels !== false && barPt < dataPt * 1.2;
1695
+ // A narrow horizontal bar chart steps its value axis coarsely so the numbers stay level; LibreOffice slants a crowded axis.
1696
+ const top = Math.max(0, ...values), low = Math.min(0, ...values);
1697
+ const tickW = ems(Math.round(Math.max(top, -low)).toLocaleString("en-US")) * dataPt;
1698
+ const catW = Math.min(w * 72 * 0.4, Math.max(0, ...labels.map(ems)) * labelPt + 8);
1699
+ const ticks = Math.floor((w * 72 - catW - 16) / (tickW * 2));
1700
+ const nice = (step) => { const p = 10 ** Math.floor(Math.log10(step)); return [1, 2, 2.5, 5, 10].find((m) => m * p >= step) * p; };
1701
+ const majorUnit = horizontal && top > low && ticks < 6 ? nice((top - low) / Math.max(1, ticks - 1)) : null;
1702
+ const zeroBased = !round && values.length > 0 && values.every((v) => v >= 0);
1703
+ // The unit stands once: a legend whose series names carry it already says it, so the axis title goes;
1704
+ // otherwise it is set level (a rotated title stacks Hangul a glyph a line and reads tiny; 360 degrees, since
1705
+ // pptxgenjs writes no angle for 0 and the axis default is vertical).
1706
+ const unitShown = spec.unit && data.length > 1 && data.every((d) => String(d.name || "").includes(spec.unit));
1707
+ const axisTitle = spec.unit && !unitShown && !(Boolean(style.valAxisHidden) && !crowded && !thin);
1708
+ slide.addChart(kind, data, {
1709
+ x, y, w, h,
1710
+ barDir: spec.type === "bar" ? "bar" : "col",
1711
+ barGrouping: spec.type === "stacked" ? "stacked" : "clustered",
1712
+ barGapWidthPct: 60,
1713
+ chartColors: round ? labels.map((_, i) => colors[i % colors.length]) : colors,
1714
+ showLegend: round || data.length > 1,
1715
+ legendPos: round ? "r" : "t",
1716
+ legendFontFace: style.bodyFont, legendFontSize: 11, legendColor: hex(style.ink),
1717
+ showValue: !round && spec.labels !== false && !crowded && !thin,
1718
+ showPercent: round && spec.labels !== false, showLabel: false,
1719
+ dataLabelFontFace: style.bodyFont, dataLabelFontSize: style.labelSize || (round ? 12 : 10),
1720
+ dataLabelColor: round ? "FFFFFF" : hex(style.ink),
1721
+ dataLabelPosition: round ? "ctr" : kind === "line" ? "t" : "outEnd",
1722
+ dataLabelFormatCode: round ? "0%" : decimals ? `#,##0.${"0".repeat(decimals)}` : "#,##0",
1723
+ catAxisLabelFontFace: style.bodyFont, catAxisLabelFontSize: style.labelSize || 11, catAxisLabelColor: hex(style.ink),
1724
+ valAxisLabelFontFace: style.bodyFont, valAxisLabelFontSize: style.labelSize || 10, valAxisLabelColor: hex(style.ink_muted),
1725
+ valAxisLabelFormatCode: "#,##0",
1726
+ // A pack with direct labels and no gridlines drops the value axis: the labels carry the values.
1727
+ valAxisHidden: Boolean(style.valAxisHidden) && !crowded && !thin,
1728
+ ...(majorUnit ? { valAxisMajorUnit: majorUnit } : {}),
1729
+ // Negative values bring the zero line into the plot; category names go to the axis minimum, off the bars.
1730
+ ...(horizontal && low < 0 ? { catAxisLabelPos: "low" } : {}),
1731
+ // Blank points leave gaps.
1732
+ displayBlanksAs: "gap",
1733
+ ...(style.valMax ? { valAxisMaxVal: style.valMax } : {}),
1734
+ ...(zeroBased ? { valAxisMinVal: 0 } : {}),
1735
+ ...(slanted ? { catAxisLabelRotate: -45 } : {}),
1736
+ catAxisLineShow: true, catAxisLineColor: hex(style.line),
1737
+ valGridLine: style.grid === false && !crowded && !thin ? { style: "none" } : { color: hex(style.line), size: 0.5 },
1738
+ valAxisLineShow: false,
1739
+ lineSize: 2.5, lineDataSymbol: "circle", lineDataSymbolSize: 7,
1740
+ holeSize: 58,
1741
+ ...(axisTitle ? { showValAxisTitle: true, valAxisTitle: spec.unit, valAxisTitleRotate: 360, valAxisTitleFontSize: style.labelSize || 10, valAxisTitleColor: hex(style.ink_muted), valAxisTitleFontFace: style.bodyFont } : {}),
1742
+ ...(spec.title ? { showTitle: true, title: spec.title, titleFontFace: style.titleFont, titleFontSize: 13, titleColor: hex(style.ink) } : {}),
1743
+ });
1744
+ }
1745
+
1746
+ /** A main-box as a tinted callout sized to its text, not an outlined frame. */
1747
+ function renderAzureCallout(slide, text, region, P) {
1748
+ const x = region.x || 0.84, y = region.y || 5.8, w = region.w || 11.6;
1749
+ const lines = Math.max(1, Math.ceil(cleanMd(text).length / Math.max(20, w * 9)));
1750
+ const h = Math.min(region.h || 1.0, 0.28 + lines * 0.3);
1751
+ slide.addShape("roundRect", { x, y, w, h, rectRadius: 0.1, fill: { color: hex(P.tint) }, line: { type: "none" } });
1752
+ slide.addText(cleanMd(text), {
1753
+ x: x + 0.24, y, w: w - 0.48, h, fontFace: P.bodyFont, fontSize: 13, bold: true,
1754
+ color: hex(P.primary_deep), align: "left", valign: "middle", margin: 0,
1525
1755
  });
1526
1756
  }
1527
1757
 
@@ -1533,7 +1763,7 @@ function renderAzureKpi(slide, content, region, P) {
1533
1763
  const n = Math.min(values.length, 4);
1534
1764
  if (!n) return;
1535
1765
  const x0 = region.x || 0.6, y0 = region.y || 1.6, w = region.w || 12.1, h = region.h || 1.7;
1536
- const gap = 0.26;
1766
+ const gap = KPI_GAP;
1537
1767
  const cardW = (w - (n - 1) * gap) / n;
1538
1768
  for (let i = 0; i < n; i++) {
1539
1769
  const fill = i === 0;
@@ -1584,7 +1814,9 @@ function kpiCards(content) {
1584
1814
  return [headers, ...rows].slice(0, 4).map((p) => ({ value: p[0], label: p[1] }));
1585
1815
  }
1586
1816
  const row = rows[0] || [];
1587
- return headers.slice(0, 4).map((lab, i) => ({ value: row[i] != null ? row[i] : "", label: lab }));
1817
+ // A second body row is each figure's basis or comparison ("전년 대비 +12%", "목표 50%").
1818
+ const basis = rows[1] || [];
1819
+ return headers.slice(0, 6).map((lab, i) => ({ value: row[i] != null ? row[i] : "", label: lab, ...(basis[i] ? { note: basis[i] } : {}) }));
1588
1820
  }
1589
1821
 
1590
1822
  /** Azure region dispatch. Returns true if it fully handled the region.
@@ -1592,9 +1824,17 @@ function kpiCards(content) {
1592
1824
  function renderAzureRegion(slide, layout, name, region, fontRoles, P, opts = {}) {
1593
1825
  const content = region.content;
1594
1826
  if (content == null) return false;
1827
+ if (name === "main_box" && typeof content === "string" && content.trim()) {
1828
+ // Below a card row that came out shorter than its region, the takeaway follows
1829
+ // the cards instead of leaving a band of empty slide between them.
1830
+ const follow = opts.flow && opts.flow.cardsBottom != null ? opts.flow.cardsBottom + 0.32 : null;
1831
+ renderAzureCallout(slide, content, follow != null && follow < (region.y || 5.8) ? { ...region, y: follow } : region, P);
1832
+ return true;
1833
+ }
1595
1834
  if (layout === "cover" && name === "title" && typeof content === "string" && content.includes("**")) {
1596
1835
  const role = fontRoles.cover_title || {};
1597
1836
  slide.addText(azureTitleRuns(content, P), {
1837
+ objectName: "title@legacy",
1598
1838
  x: region.x || 0, y: region.y || 0, w: region.w || 8, h: region.h || 1,
1599
1839
  fontFace: role.font || P.titleFont, fontSize: role.size || 48, bold: role.bold !== false,
1600
1840
  align: role.align || "left", valign: "middle",
@@ -1609,21 +1849,28 @@ function renderAzureRegion(slide, layout, name, region, fontRoles, P, opts = {})
1609
1849
  // normal text column (fall through to addRegion) so nothing is cramped.
1610
1850
  const sections = (content.items || []).filter((i) => i.type === "section");
1611
1851
  if (sections.length >= 2 && !opts.hasImage) {
1612
- renderAzureCards(slide, content.items, region, P);
1852
+ const bottom = renderAzureCards(slide, content.items, region, P);
1853
+ if (opts.flow) opts.flow.cardsBottom = bottom;
1613
1854
  return true;
1614
1855
  }
1615
1856
  return false;
1616
1857
  }
1617
1858
  if (content.type === "kpi-table") {
1618
- if (looksLikeKpi(content) && kpiFits(content, region.w)) {
1859
+ if (content.chart) {
1860
+ // Pie and doughnut slices carry white labels, so they use the darker tones only.
1861
+ const round = ["pie", "doughnut"].includes(content.chart.type);
1862
+ const series = round ? [P.primary, P.primary_deep, P.azure, P.ink_muted] : [P.primary, P.azure_soft, P.primary_deep, P.ink_muted];
1863
+ renderChart(slide, content, region, { ...P, series }, opts.bottom);
1864
+ }
1865
+ else if (looksLikeKpi(content) && kpiFits(content, region.w)) {
1619
1866
  renderAzureKpi(slide, content, region, P);
1620
1867
  }
1621
- else renderAzureTable(slide, content, region, P);
1868
+ else renderAzureTable(slide, content, region, P, opts.bottom);
1622
1869
  return true;
1623
1870
  }
1624
1871
  if (content.type === "summary-group") {
1625
1872
  const idx = name === "group_bottom" ? 1 : 0;
1626
- renderAzureGroupCard(slide, content, region, P, idx);
1873
+ renderAzureGroupCard(slide, content, region, P, idx, opts.groupHeight);
1627
1874
  return true;
1628
1875
  }
1629
1876
  }
@@ -1743,7 +1990,7 @@ function renderPlacements(slide, placements, band, ctx) {
1743
1990
  for (const placement of placements) {
1744
1991
  if (placement.z !== band) continue;
1745
1992
  if (placement.kind === "shape") {
1746
- addDecorations(slide, [shapeToDecoration(placement, ctx.sourceDir)], ctx.slideIndex, ctx.fontRoles);
1993
+ addDecorations(slide, [shapeToDecoration(placement, ctx.sourceDir)], ctx.fontRoles);
1747
1994
  } else {
1748
1995
  renderPlacementBox(slide, placement, ctx);
1749
1996
  }
@@ -1760,11 +2007,67 @@ function renderPlacements(slide, placements, band, ctx) {
1760
2007
  * @param {string} outputPath - Where to write the .pptx file
1761
2008
  * @returns {Promise<string>} Resolves with the output path
1762
2009
  */
2010
+ /**
2011
+ * The deck-wide `notice:` (for example "예시 데이터 — 실제 수치로 바꿔 주세요") as a
2012
+ * coloured tag at the bottom left of every slide, cover included, so a sample or
2013
+ * draft deck can never be mistaken for a sourced one. A 9 pt grey footnote was easy
2014
+ * to miss; the tag keeps its own fill and text colour (template palette
2015
+ * `notice_fill` / `notice_ink`, default a light red with dark red text, 6:1).
2016
+ */
2017
+ const NOTICE_FILL = "FDECEA";
2018
+ const NOTICE_INK = "A21B12";
2019
+ let NOTICE = { ...LEGACY_TOKENS.notice };
2020
+
2021
+ function addNotice(slide, text, pageW, pageH, pal) {
2022
+ const label = cleanMd(text);
2023
+ const fontSize = NOTICE.size;
2024
+ const textW = (weightedTextLength(label) * fontSize * 0.98) / 72;
2025
+ const w = Math.min(pageW * 0.55, textW + 0.46);
2026
+ const h = NOTICE.h;
2027
+ const x = NOTICE.x, y = pageH - h - NOTICE.bottom;
2028
+ const fill = ((pal && pal.notice_fill) || NOTICE_FILL).replace("#", "");
2029
+ const ink = ((pal && pal.notice_ink) || NOTICE_INK).replace("#", "");
2030
+ slide.addShape("roundRect", { x, y, w, h, rectRadius: 0.15, fill: { color: fill }, line: { type: "none" }, objectName: "lit-notice tag" });
2031
+ slide.addText(label, {
2032
+ objectName: "lit-notice text",
2033
+ x: x + 0.16, y, w: w - 0.32, h,
2034
+ fontFace: FONT_BOLD, fontSize, bold: true, color: ink,
2035
+ align: "left", valign: "middle", margin: 0, fit: "shrink",
2036
+ });
2037
+ }
2038
+
2039
+ /**
2040
+ * Give the first object drawn on a slide that has no name of its own the name `family@<id>`, so a
2041
+ * checker can read the layout family the source asked for and compare it with what it measures.
2042
+ */
2043
+ function stampFamily(slide, family) {
2044
+ const at = { addText: 1, addShape: 1, addImage: 0, addTable: 1, addChart: 2 };
2045
+ const originals = {};
2046
+ let done = false;
2047
+ const restore = () => { for (const m of Object.keys(originals)) slide[m] = originals[m]; };
2048
+ for (const [method, index] of Object.entries(at)) {
2049
+ originals[method] = slide[method];
2050
+ slide[method] = (...args) => {
2051
+ if (!(args[index] && args[index].objectName)) {
2052
+ restore();
2053
+ done = true;
2054
+ args[index] = { ...(args[index] || {}), objectName: `family@${family}` };
2055
+ }
2056
+ return originals[method].apply(slide, args);
2057
+ };
2058
+ }
2059
+ /** Whether a shape took the name; unwraps the slide either way. */
2060
+ return () => { restore(); return done; };
2061
+ }
2062
+
1763
2063
  async function render(resolved, templateObj, outputPath) {
2064
+ const pack = (templateObj && templateObj.pack) || null;
2065
+ if (pack && !pack.legacy) return require("./render-pack").renderPack(resolved, templateObj, outputPath);
2066
+ applyTokens(pack && pack.tokens);
1764
2067
  const fontRoles = buildFontRoles(templateObj);
1765
2068
 
1766
2069
  // Brand-neutralize: derive font/spacing/accent from the template, falling back
1767
- // to the generic Pretendard fallback defaults so enrolled templates provide their own fonts.
2070
+ // to Pretendard when a template names no family.
1768
2071
  const tpl = (templateObj && templateObj.template) || {};
1769
2072
  const tplFonts = tpl.fonts || {};
1770
2073
  const tplType = tpl.global_typography || {};
@@ -1772,21 +2075,25 @@ async function render(resolved, templateObj, outputPath) {
1772
2075
  FONT_MEDIUM = tplFonts.body || "Pretendard";
1773
2076
  FONT_LIGHT = tplFonts.light || "Pretendard";
1774
2077
  CHAR_SPACING = tplType.char_spacing != null ? tplType.char_spacing : -0.7;
1775
- BULLET_FONT = FONT_BOLD;
1776
2078
  SEC_HEADER_FONT = FONT_BOLD;
1777
2079
  TOC_FONT = FONT_BOLD;
1778
- BULLET_COLOR = (tplType.bullet_color || "00AE41").replace("#", "");
1779
- SECTION_ACCENT = (tplType.section_accent || "00AE41").replace("#", "");
2080
+ BULLET_COLOR = (tplType.bullet_color || "1D4ED8").replace("#", "");
2081
+ SECTION_ACCENT = (tplType.section_accent || "1D4ED8").replace("#", "");
1780
2082
 
1781
- // Rich card/KPI/divider rendering — palette-driven, shared by render_style
1782
- // "azure" (blue) and "green" (green). Tokens come from the template palette.
1783
- const rich = ["azure", "green"].includes(tpl.render_style);
2083
+ // Rich card/KPI/divider rendering — palette-driven, for templates with the rich-blocks
2084
+ // capability. Tokens come from the template palette.
2085
+ const rich = Boolean(pack && (pack.capabilities || []).includes("rich-blocks"));
1784
2086
  const pal = tpl.palette || {};
2087
+ PLAIN_CHART_STYLE = {
2088
+ series: [pal.ink || "#3C3836", pal.accent_green_2 || pal.primary || "#689D6A", pal.accent_amber || "#D79921", pal.accent_purple || "#B16286"],
2089
+ ink: pal.ink || "#3C3836", ink_muted: pal.ink_muted || "#6E6256", line: pal.line || "#CBC2B5",
2090
+ titleFont: FONT_BOLD, bodyFont: FONT_MEDIUM,
2091
+ };
1785
2092
  const P = {
1786
2093
  titleFont: FONT_BOLD, bodyFont: FONT_MEDIUM,
1787
2094
  primary: pal.primary || "#1D4ED8", primary_deep: pal.primary_deep || "#0B2E6F",
1788
2095
  ink: pal.ink || "#0E1B2C", ink_muted: pal.ink_muted || "#51607A",
1789
- tint: pal.tint || "#EEF4FF", line: pal.line || "#D5DEEC", azure_soft: pal.azure_soft || "#93C5FD",
2096
+ tint: pal.tint || "#EEF4FF", line: pal.line || "#D5DEEC", azure_soft: pal.azure_soft || "#93C5FD", azure: pal.azure || "#3B82F6",
1790
2097
  emphasisTextColor: pal.emphasis_text || "#C9D8F5",
1791
2098
  };
1792
2099
 
@@ -1795,6 +2102,8 @@ async function render(resolved, templateObj, outputPath) {
1795
2102
  const pageW = dims.width || 10.833;
1796
2103
  const pageH = dims.height || 7.5;
1797
2104
 
2105
+ // Loaded here, not at module load, so AST-only and --list-* runs never install.
2106
+ const PptxGenJS = require("pptxgenjs");
1798
2107
  const pptx = new PptxGenJS();
1799
2108
  pptx.defineLayout({ name: "A4_LANDSCAPE", width: pageW, height: pageH });
1800
2109
  pptx.layout = "A4_LANDSCAPE";
@@ -1803,20 +2112,22 @@ async function render(resolved, templateObj, outputPath) {
1803
2112
  pptx.title = title;
1804
2113
  const sourceDir = resolved.sourceDir;
1805
2114
  const cramStress = isCramStressDeck(resolved);
2115
+ const deckMeta = (resolved.deck && resolved.deck.metadata) || {};
2116
+ const notice = typeof deckMeta.notice === "string" && deckMeta.notice.trim() ? deckMeta.notice.trim() : null;
1806
2117
 
1807
2118
  for (const slideSpec of resolved.slides || []) {
1808
2119
  const slide = pptx.addSlide();
2120
+ stampFamily(slide, slideSpec.family || slideSpec.layout);
1809
2121
 
1810
2122
  // Draw order is a band at a time: template decorations, then anything the
1811
2123
  // author put under the content, then the template's regions, then the
1812
2124
  // columns that replaced the body, then anything sitting on top.
1813
- addDecorations(slide, slideSpec.decorations, slideSpec.index, fontRoles);
2125
+ addDecorations(slide, slideSpec.decorations, fontRoles);
1814
2126
 
1815
2127
  const placements = slideSpec.placements || [];
1816
2128
  const placementCtx = {
1817
2129
  rich, P, fontRoles, pageH, sourceDir,
1818
2130
  layout: slideSpec.layout,
1819
- slideIndex: slideSpec.index,
1820
2131
  };
1821
2132
  renderPlacements(slide, placements, "under", placementCtx);
1822
2133
 
@@ -1846,17 +2157,80 @@ async function render(resolved, templateObj, outputPath) {
1846
2157
  (n === "image" || n === "figure") && r.content &&
1847
2158
  (r.content.type === "image" || r.content.type === "image-grid"));
1848
2159
 
2160
+ // Summary groups sit side by side, so they share the height the fuller one needs.
2161
+ const groups = Object.values(regions).filter((r) => r.content && r.content.type === "summary-group");
2162
+ const groupHeight = rich && groups.length
2163
+ ? Math.max(...groups.map((r) => groupCard(r.content, r.w || 5.6, r.h || 2.4, P).need))
2164
+ : null;
2165
+
2166
+ const flow = {};
1849
2167
  for (const [name, region] of Object.entries(regions)) {
1850
- if (rich && renderAzureRegion(slide, slideSpec.layout, name, region, fontRoles, P, { hasImage })) continue;
2168
+ if (rich && renderAzureRegion(slide, slideSpec.layout, name, region, fontRoles, P, { hasImage, bottom: bottomBoundary[name], groupHeight, flow })) continue;
1851
2169
  addRegion(slide, name, region, fontRoles, pageH, bottomBoundary[name], tocSlide, sourceDir);
1852
2170
  }
1853
2171
 
1854
2172
  renderPlacements(slide, placements, "content", placementCtx);
1855
2173
  renderPlacements(slide, placements, "over", placementCtx);
2174
+ if (notice) addNotice(slide, notice, pageW, pageH, pal);
1856
2175
  }
1857
2176
 
1858
- await pptx.writeFile({ fileName: outputPath });
2177
+ await writeTagged(pptx, outputPath);
1859
2178
  return outputPath;
1860
2179
  }
1861
2180
 
1862
- module.exports = { render };
2181
+ const HANGUL = /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7A3]/u;
2182
+
2183
+ /**
2184
+ * Korean runs carry their language. pptxgenjs writes lang="en-US" on every run, and a renderer then sets
2185
+ * Hangul by Latin rules (LibreOffice under a non-Korean system locale puts space between Hangul and
2186
+ * digits, so "주 2일" reads "주 2 일"). A run holding Hangul becomes ko-KR with en-US as its alternate
2187
+ * language, and a chart holding Hangul names ko-KR for its text; Latin-only runs keep en-US.
2188
+ */
2189
+ function tagLanguages(name, xml) {
2190
+ if (/^ppt\/slides\/slide\d+\.xml$/u.test(name)) {
2191
+ return xml.replace(/<a:r><a:rPr\b([^>]*?)(\/?>)([\s\S]*?)<a:t>([^<]*)<\/a:t><\/a:r>/gu, (m, attrs, close, mid, text) => {
2192
+ if (!HANGUL.test(text)) return m;
2193
+ const rest = attrs.replace(/\s(?:lang|altLang)="[^"]*"/gu, "");
2194
+ return `<a:r><a:rPr lang="ko-KR" altLang="en-US"${rest}${close}${mid}<a:t>${text}</a:t></a:r>`;
2195
+ });
2196
+ }
2197
+ if (/^ppt\/charts\/chart\d+\.xml$/u.test(name) && HANGUL.test(xml)) {
2198
+ let out = xml.replace(/<a:defRPr\b(?![^>]*\blang=)/gu, '<a:defRPr lang="ko-KR" altLang="en-US"')
2199
+ .replace(/<a:endParaRPr lang="en-US"/gu, '<a:endParaRPr lang="ko-KR" altLang="en-US"');
2200
+ if (!/<c:lang\b/u.test(out)) out = out.replace(/(<c:date1904 val="[01]"\/>)/u, '$1<c:lang val="ko-KR"/>');
2201
+ return out;
2202
+ }
2203
+ return xml;
2204
+ }
2205
+
2206
+ /** Write a deck with its Korean runs and charts tagged (tagLanguages); `edit` may rewrite a part first. */
2207
+ async function writeTagged(pptx, outputPath, edit = (name, xml) => xml) {
2208
+ const JSZip = require("jszip");
2209
+ const zip = await JSZip.loadAsync(await pptx.write({ outputType: "nodebuffer" }));
2210
+ for (const name of Object.keys(zip.files).filter((n) => /^ppt\/(slides\/slide|charts\/chart|theme\/theme)\d+\.xml$/u.test(n))) {
2211
+ const xml = await zip.file(name).async("string");
2212
+ zip.file(name, tagLanguages(name, edit(name, xml)));
2213
+ }
2214
+ fs.writeFileSync(outputPath, await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }));
2215
+ }
2216
+
2217
+ // ── Tonality packs ──────────────────────────────────────────────────────────
2218
+ //
2219
+ // A pack deck is drawn by render-pack.js. It reuses the helpers below, and the font globals the
2220
+ // free placement boxes read are switched to the pack's faces before it draws.
2221
+
2222
+ function usePackFonts(titleFont, bodyFont, tokens) {
2223
+ FONT_BOLD = titleFont;
2224
+ FONT_MEDIUM = bodyFont;
2225
+ FONT_LIGHT = bodyFont;
2226
+ CHAR_SPACING = 0;
2227
+ applyTokens(tokens);
2228
+ }
2229
+
2230
+ module.exports = {
2231
+ render,
2232
+ shared: {
2233
+ hex, cleanMd, mdRuns, linkedTableCell, resolveImagePath, readImageSize, containBox,
2234
+ kpiCards, numericColumns, proportionalWidths, renderChart, stampFamily, renderPlacements, usePackFonts, writeTagged,
2235
+ },
2236
+ };